Skip to content
RY

Removed Support for Long-Deprecated Dynamic Controller/Action in Routes in Rails

Ram Laxman Yadav6 min read

rails/rails#58893 removes something that’s been deprecated since Rails 5.0 in 2016: a route that reads its controller and action out of the URL itself, instead of having them fixed when the route is drawn. Drawing one now raises an ArgumentError instead of a deprecation warning. That’s the change in one sentence — the interesting part is why this one pattern needed a decade-long deprecation window and a dedicated removal PR, rather than just deleting a method.

Root cause: the URL used to name the Ruby method directly

Early Rails shipped a default route that every generated application carried:

get ":controller(/:action(/:id))"

That single line meant /photos/show/1 dispatched to PhotosController#show with id: "1" — and so did /users/destroy/42, /admin/accounts/update/7, or any other /controller_name/action_name/id combination that happened to exist. The controller and action weren’t choices you made when you wrote the route; they were whatever string showed up in the URL. That’s the actual root cause this PR is finishing off: a routing style where the set of reachable actions in your application was defined by every public method on every controller, discoverable by guessing URLs, rather than by the routes file.

That design had a second-order cost inside Rails itself. Because the controller named by a request was only known once the request arrived, the dispatcher couldn’t assume a controller class existed at all — it had to carry a raise_on_name_error flag and a PASS_NOT_FOUND placeholder class specifically to turn “no such controller” into a quiet 404 instead of a crash:

PASS_NOT_FOUND = Class.new { # :nodoc:
def self.action(_); self; end
def self.call(_); [404, { Constants::X_CASCADE => "pass" }, []]; end
def self.action_encoding_template(action); false; end
}

Rails 5.0 deprecated the pattern; this PR deletes the machinery that existed to keep it limping along.

What drawing the old route does now

Rails.application.routes.draw do
get ":controller(/:action(/:id))"
end
ArgumentError: Using a dynamic :controller segment in a route is not supported: /:controller(/:action(/:id))(.:format).
Specify the controller and action explicitly, e.g. `get "/photos/:id", to: "photos#show"`.

The fix is the same one the deprecation warning has been telling you since 2016 — write out the routes your app actually serves:

Rails.application.routes.draw do
get "photos", to: "photos#index"
get "photos/:id", to: "photos#show"
end

bin/rails routes shows what your app currently exposes, and bin/rails unused_routes flags routes that no longer point at a real action — both useful while converting a legacy catch-all into an explicit list.

The namespaced catch-all trick is gone too

A more targeted variant of the same pattern combined a dynamic :controller segment with a Regexp constraint, to expose a whitelisted set of namespaced controllers through one route:

get "/:controller(/:action(/:id))", controller: /admin\/(accounts|users)/

This used to resolve /admin/accounts to { controller: "admin/accounts", action: "index" } and /admin/users similarly, while /admin/products correctly 404’d. Mapper even injected a default constraint (options[:controller] ||= /.+?/) to make plain namespaced dynamic routes like /admin/products/show/1 resolve at all. Both halves of that mechanism are gone: a dynamic :controller/:action segment raises regardless of any constraint you attach to it, and passing a Regexp for :controller or :action is now rejected outright — get "photos", controller: /photos/, action: "index" raises ArgumentError instead of silently drawing a route that could never actually match a request.

Passing a bare controller class now needs an explicit action

The same dynamic-segment machinery was quietly backing this shorthand too:

# Before
get ":action", to: PhotosController

The action used to come from the :action segment at request time. With that gone, it has to be given up front:

# After
get "show", to: PhotosController, action: "show"

The consequence: Rails can finally validate routes at boot

Because a route’s controller used to be unknowable until a request arrived, Rails could never check in advance whether to: "photos#show" actually points at a real class — it had to wait and 404 gracefully if not. With every route’s controller fixed at draw time, that check becomes possible, and this PR adds it:

actionpack/lib/action_dispatch/routing/route_set.rb
def missing_controller_messages # :nodoc:
request = request_class.empty
routes.filter_map do |route|
next unless route.dispatcher?
next if StaticDispatcher === route.app
begin
request.controller_class_for(route.defaults[:controller])
nil
rescue MissingController => error
message = +"#{route.verb} #{route.path.spec} references a missing controller: #{error.message}"
message << " (defined at #{route.source_location})" if route.source_location
message
end
end
end

Rails::Application::RoutesReloader calls this once routes are loaded, but only when config.eager_load is on:

if eager_load
route_sets.each(&:eager_load!)
warn_about_missing_controllers
end
GET /photos(.:format) references a missing controller: uninitialized constant PhotosController

A typo’d controller name or a controller deleted during a refactor now shows up in the boot log of any eager-loading environment (production, by default), instead of sitting silent until the first real request hits that exact route.

Request#controller_class raises instead of pretending

PASS_NOT_FOUND is gone from ActionDispatch::Request entirely. Calling controller_class on a request that was never routed to a controller — before routing ran, or on a request handed to a plain Rack endpoint — now raises explicitly instead of silently returning a stand-in object that faked a 404 response:

request.controller_class
# => ActionDispatch::MissingController:
# No :controller in path parameters; the request has not been routed to a controller

Note

Two of the new test cases spell out the distinction precisely: controller_class raises MissingController both when there’s no :controller key in the path parameters at all, and separately when there is one but the constant it names doesn’t exist. Previously only the first case was distinguishable from the outside — the second just quietly 404’d.

Pros

  • Deletes a real security foot-gun. With the catch-all route gone for good, no application can accidentally reintroduce URL-driven dispatch where any public controller method is reachable by guessing a path.
  • Simpler internals. Dispatcher no longer takes a raise_on_name_error flag, PASS_NOT_FOUND is gone, and Journey::Visitors no longer special-cases :controller to allow slashes in a segment — all of it existed solely to support this one pattern.
  • Fails loudly instead of quietly. A Regexp for :controller/:action, a bare controller class with no action:, or a dynamic segment all raise ArgumentError at route-draw time now, rather than drawing a route that silently never matches or behaves ambiguously.
  • New boot-time safety net. The missing-controller check this removal enables catches typos and dead routes before a user ever hits them, in any environment running with eager loading on.

Cons

  • Zero migration runway left. The deprecation ran for roughly a decade (Rails 5.0 onward), but any application or gem that still draws the literal catch-all route breaks immediately on upgrade — there’s no further grace period, just an ArgumentError.
  • Breaks implicit admin-style dispatch. Older internal tools or gems that leaned on /:controller/:action/:id plus a Regexp constraint to serve a family of namespaced controllers have to be rewritten as explicit routes; there’s no equivalent shortcut.
  • The new safety net is opt-in by environment. warn_about_missing_controllers only runs when config.eager_load is true, so a team running development without eager loading (the default) won’t see these warnings until they deploy somewhere that does.
  • One more exception type to handle. Code that rescued ActionController::RoutingError around controller_class to detect an unrouted request now needs to handle ActionDispatch::MissingController instead.

For the full diff, discussion, and the exact test changes, see rails/rails#58893.