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))"endArgumentError: 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"endbin/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:
# Beforeget ":action", to: PhotosControllerThe action used to come from the :action segment at request time. With that gone, it has to be given up front:
# Afterget "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:
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 endendRails::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_controllersendGET /photos(.:format) references a missing controller: uninitialized constant PhotosControllerA 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 controllerNote
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.
Dispatcherno longer takes araise_on_name_errorflag,PASS_NOT_FOUNDis gone, andJourney::Visitorsno longer special-cases:controllerto allow slashes in a segment — all of it existed solely to support this one pattern. - Fails loudly instead of quietly. A
Regexpfor:controller/:action, a bare controller class with noaction:, or a dynamic segment all raiseArgumentErrorat 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/:idplus aRegexpconstraint 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_controllersonly runs whenconfig.eager_loadis true, so a team runningdevelopmentwithout 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::RoutingErroraroundcontroller_classto detect an unrouted request now needs to handleActionDispatch::MissingControllerinstead.
For the full diff, discussion, and the exact test changes, see rails/rails#58893.