From d16a12d3f197f68ed6f23740863613e14afb976c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Mon, 29 Jun 2026 15:30:23 +0200 Subject: [PATCH 01/13] Split routes by verb during compilation This pull request compiles routes by verb, which speeds up compilation times for large routes. This is done by making sure `match/forward` statements always come last. Therefore, this pull request changes the semantics of *dead routes*. If you had this code: match "/foo" get "/foo" The second route would never be matched but it is now as part of this pull request. However, `get "/foo"` should not be there in the first place. So the routes should either be rearranged or removed anyway. --- lib/phoenix/router.ex | 70 ++++++++++++++++++++++------ lib/phoenix/router/route.ex | 6 +-- test/phoenix/router/routing_test.exs | 2 +- 3 files changed, 57 insertions(+), 21 deletions(-) diff --git a/lib/phoenix/router.ex b/lib/phoenix/router.ex index e63357bb5b..f09c4737da 100644 --- a/lib/phoenix/router.ex +++ b/lib/phoenix/router.ex @@ -457,7 +457,7 @@ defmodule Phoenix.Router do %{method: method, path_info: path_info, host: host} = conn = prepare(conn) decoded = Enum.map(path_info, &URI.decode/1) - case __match_route__(decoded, method, host) do + case __match_route__(method, decoded, host) do {metadata, prepare, pipeline, plug_opts} -> Phoenix.Router.__call__(conn, metadata, prepare, pipeline, plug_opts) @@ -494,8 +494,15 @@ defmodule Phoenix.Router do Helpers.define(env, routes_with_exprs) end + # Group routes by verb making sure the ones that match all are handled last {matches, {pipelines, _}} = - Enum.map_reduce(routes_with_exprs, {[], %{}}, &build_match/2) + routes_with_exprs + |> Enum.group_by(&elem(&1, 0).verb) + |> Map.pop(:*, []) + |> then(fn {match_routes_exprs, map} -> + Map.to_list(map) ++ [{:*, match_routes_exprs}] + end) + |> Enum.map_reduce({[], %{}}, &build_match_verb/2) routes_per_path = routes_with_exprs @@ -515,14 +522,6 @@ defmodule Phoenix.Router do end end - match_catch_all = - quote generated: true do - @doc false - def __match_route__(_path_info, _verb, _host) do - :error - end - end - forward_catch_all = quote generated: true do @doc false @@ -560,7 +559,6 @@ defmodule Phoenix.Router do unquote(verifies) unquote(verify_catch_all) unquote(matches) - unquote(match_catch_all) unquote(forward_catch_all) end end @@ -591,13 +589,55 @@ defmodule Phoenix.Router do end end - defp build_match({route, expr}, {acc_pipes, known_pipes}) do + defp build_match_verb({:*, routes_exprs}, acc) do + name = :__match_route_catch_all__ + {clauses, acc} = Enum.map_reduce(routes_exprs, acc, &build_match_path(name, &1, &2)) + + dispatch = + quote generated: true do + unquote({:__block__, [], clauses}) + + defp __match_route_catch_all__(_path, _host) do + :error + end + + @doc false + def __match_route__(_, path, host) do + __match_route_catch_all__(path, host) + end + end + + {dispatch, acc} + end + + defp build_match_verb({verb, routes_exprs}, acc) do + pattern = verb |> to_string() |> String.upcase() + name = :"__match_route_#{verb}__" + + {clauses, acc} = Enum.map_reduce(routes_exprs, acc, &build_match_path(name, &1, &2)) + + dispatch = + quote generated: true do + unquote({:__block__, [], clauses}) + + defp unquote(name)(path, host) do + __match_route_catch_all__(path, host) + end + + def __match_route__(unquote(pattern), path, host) do + unquote(name)(path, host) + end + end + + {dispatch, acc} + end + + defp build_match_path(name, {route, expr}, {acc_pipes, known_pipes}) do {pipe_name, acc_pipes, known_pipes} = build_match_pipes(route, acc_pipes, known_pipes) %{ prepare: prepare, dispatch: dispatch, - verb_match: verb_match, path_params: path_params, hosts: hosts, path: path @@ -606,7 +646,7 @@ defmodule Phoenix.Router do clauses = for host <- hosts do quote line: route.line do - def __match_route__(unquote(path), unquote(verb_match), unquote(host)) do + defp unquote(name)(unquote(path), unquote(host)) do {unquote(build_metadata(route, path_params)), fn var!(conn, :conn), %{path_params: var!(path_params, :conn)} -> unquote(prepare) @@ -1266,7 +1306,7 @@ defmodule Phoenix.Router do def route_info(router, method, split_path, host) when is_list(split_path) do with {metadata, _prepare, _pipeline, {_plug, _opts}} <- - router.__match_route__(split_path, method, host) do + router.__match_route__(method, split_path, host) do Map.delete(metadata, :conn) end end diff --git a/lib/phoenix/router/route.ex b/lib/phoenix/router/route.ex index bc2f1b7999..f7d1cddb67 100644 --- a/lib/phoenix/router/route.ex +++ b/lib/phoenix/router/route.ex @@ -127,8 +127,7 @@ defmodule Phoenix.Router.Route do dispatch: build_dispatch(route), hosts: build_host_match(route.hosts), path_params: build_path_params(binding), - prepare: build_prepare(route), - verb_match: verb_match(route.verb) + prepare: build_prepare(route) } end @@ -138,9 +137,6 @@ defmodule Phoenix.Router.Route do for host <- hosts, do: Plug.Router.Utils.build_host_match(host) end - defp verb_match(:*), do: Macro.var(:_verb, nil) - defp verb_match(verb), do: verb |> to_string() |> String.upcase() - defp build_path_params(binding), do: {:%{}, [], binding} defp build_path_and_binding(%Route{path: path} = route) do diff --git a/test/phoenix/router/routing_test.exs b/test/phoenix/router/routing_test.exs index 5142294df5..0818d40ab8 100644 --- a/test/phoenix/router/routing_test.exs +++ b/test/phoenix/router/routing_test.exs @@ -39,6 +39,7 @@ defmodule Phoenix.Router.RoutingTest do defmodule Router do use Phoenix.Router + import ExUnit.Assertions, except: [trace: 3] get "/", UserController, :index, as: :users get "/users/top", UserController, :top, as: :top @@ -52,7 +53,6 @@ defmodule Phoenix.Router.RoutingTest do get "/static/images/icons/*image", UserController, :image get "/exit", UserController, :exit get "/halt-controller", UserController, :halt - trace("/trace", UserController, :trace) options "/options", UserController, :options connect "/connect", UserController, :connect From a2abbbb2fa47f4c6dcda2649d3996d2b1f5fb684 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Mon, 29 Jun 2026 18:27:45 +0200 Subject: [PATCH 02/13] Fixes --- lib/phoenix/router.ex | 69 +++++++++++++++++++++++----- test/phoenix/router/route_test.exs | 3 -- test/phoenix/router/routing_test.exs | 27 +++++------ 3 files changed, 71 insertions(+), 28 deletions(-) diff --git a/lib/phoenix/router.ex b/lib/phoenix/router.ex index f09c4737da..848dd44846 100644 --- a/lib/phoenix/router.ex +++ b/lib/phoenix/router.ex @@ -505,14 +505,13 @@ defmodule Phoenix.Router do |> Enum.map_reduce({[], %{}}, &build_match_verb/2) routes_per_path = - routes_with_exprs - |> Enum.group_by(&elem(&1, 1).path, &elem(&1, 0)) + Enum.group_by(routes_with_exprs, &elem(&1, 1).path, &elem(&1, 0)) verifies = routes_with_exprs |> Enum.map(&elem(&1, 1).path) |> Enum.uniq() - |> Enum.map(&build_verify(&1, routes_per_path)) + |> Enum.map(&build_verify(&1, routes_per_path, env)) verify_catch_all = quote generated: true do @@ -563,11 +562,11 @@ defmodule Phoenix.Router do end end - defp build_verify(path, routes_per_path) do + defp build_verify(path, routes_per_path, env) do routes = Map.get(routes_per_path, path) warn_on_verify? = Enum.all?(routes, & &1.warn_on_verify?) - case Enum.find(routes, &(&1.kind == :forward)) do + case find_forward_and_check_status(routes, nil, nil, env) do %{metadata: %{forward: forward}, plug: plug, plug_opts: plug_opts} -> quote generated: true do def __forward__(unquote(plug)) do @@ -589,6 +588,43 @@ defmodule Phoenix.Router do end end + defp find_forward_and_check_status( + [%{kind: kind, verb: verb} = route | routes], + forward, + star_route, + env + ) do + forward = + if kind == :forward and is_nil(forward) do + route + else + forward + end + + star_route = + cond do + star_route -> + IO.warn( + "found route matching on #{inspect(route.path)} after match(:*, #{inspect(star_route.path)})", + Macro.Env.stacktrace(%{env | line: route.line}) + ) + + star_route + + verb == :* -> + route + + true -> + nil + end + + find_forward_and_check_status(routes, forward, star_route, env) + end + + defp find_forward_and_check_status([], forward, _star_route, _env) do + forward + end + defp build_match_verb({:*, routes_exprs}, acc) do name = :__match_route_catch_all__ {clauses, acc} = Enum.map_reduce(routes_exprs, acc, &build_match_path(name, &1, &2)) @@ -716,28 +752,37 @@ defmodule Phoenix.Router do Useful for defining routes not included in the built-in macros. The catch-all verb, `:*`, may also be used to match all HTTP methods. + However, keep in mind that routes with the catch-all verb are always + matched last. Therefore it is recommended that all `match :*` routes + are defined at the end of the file. ## Options * `:as` - configures the named helper. If `nil`, does not generate a helper. Has no effect when using verified routes exclusively + * `:alias` - configure if the scope alias should be applied to the route. - Defaults to true, disables scoping if false. + Defaults to true, disables scoping if false + * `:log` - the level to log the route dispatching under, may be set to false. Defaults to `:debug`. Route dispatching contains information about how the route is handled (which controller action is called, what parameters are available and which pipelines are used) and is separate from the plug level logging. To alter the plug log level, please see - https://phoenix.hexdocs.pm/Phoenix.Logger.html#module-dynamic-log-level. + https://phoenix.hexdocs.pm/Phoenix.Logger.html#module-dynamic-log-level + * `:private` - a map of private data to merge into the connection when a route matches + * `:assigns` - a map of data to merge into the connection when a route matches + * `:metadata` - a map of metadata used by the telemetry events and returned by `route_info/4`. The `:mfa` field is used by telemetry to print logs and by the - router to emit compile time checks. Custom fields may be added. - * `:warn_on_verify` - the boolean for whether matches to this route trigger - an unmatched route warning for `Phoenix.VerifiedRoutes`. It is useful to ignore - an otherwise catch-all route definition from being matched when verifying routes. - Defaults `false`. + router to emit compile time checks. Custom fields may be added + + * `:warn_on_verify` - the boolean for whether matches to this route in verified + routes should emit a warning, rather than being accepted as verified. It is useful + to ignore an otherwise catch-all route definition from being matched when verifying + routes. Defaults `false` ## Examples diff --git a/test/phoenix/router/route_test.exs b/test/phoenix/router/route_test.exs index 4ade1cb03a..0d7f4810d2 100644 --- a/test/phoenix/router/route_test.exs +++ b/test/phoenix/router/route_test.exs @@ -64,7 +64,6 @@ defmodule Phoenix.Router.RouteTest do ) |> exprs() - assert exprs.verb_match == "GET" assert exprs.path == ["foo", {:arg0, [], Phoenix.Router.Route}] assert exprs.binding == [{"bar", {:arg0, [], Phoenix.Router.Route}}] assert Macro.to_string(exprs.hosts) == "[_]" @@ -154,7 +153,6 @@ defmodule Phoenix.Router.RouteTest do assert route.verb == :* assert route.kind == :match - assert exprs(route).verb_match == {:_verb, [], nil} end test "builds a catch-all verb_match for forwarded routes" do @@ -178,7 +176,6 @@ defmodule Phoenix.Router.RouteTest do assert route.verb == :* assert route.kind == :forward - assert exprs(route).verb_match == {:_verb, [], nil} end test "as a plug, it forwards and sets path_info and script_name for target, then resumes" do diff --git a/test/phoenix/router/routing_test.exs b/test/phoenix/router/routing_test.exs index 0818d40ab8..060be17a63 100644 --- a/test/phoenix/router/routing_test.exs +++ b/test/phoenix/router/routing_test.exs @@ -57,7 +57,6 @@ defmodule Phoenix.Router.RoutingTest do options "/options", UserController, :options connect "/connect", UserController, :connect match :move, "/move", UserController, :move - match :*, "/any", UserController, :any scope log: :info do pipe_through :noop @@ -224,18 +223,6 @@ defmodule Phoenix.Router.RoutingTest do assert conn.resp_body == "users move" end - test "any verb matches" do - conn = call(Router, :get, "/any") - assert conn.method == "GET" - assert conn.status == 200 - assert conn.resp_body == "users any" - - conn = call(Router, :put, "/any") - assert conn.method == "PUT" - assert conn.status == 200 - assert conn.resp_body == "users any" - end - test "different verbs with similar paths" do conn = call(Router, :post, "/users/fallback") assert conn.status == 200 @@ -248,6 +235,20 @@ defmodule Phoenix.Router.RoutingTest do assert conn.path_params["id"] == "123" end + describe "warnings" do + test "warns on duplicate route after :*" do + assert ExUnit.CaptureIO.capture_io(:stderr, fn -> + defmodule MatchOverlap do + use Phoenix.Router + import ExUnit.Assertions, except: [trace: 3] + + match :*, "/foo/:bar", UserController, :index + get "/foo/:baz", UserController, :index + end + end) =~ "found route matching on \"/foo/:baz\" after match(:*, \"/foo/:bar\")" + end + end + describe "logging" do setup do Logger.delete_process_level(self()) From 3363c7e5c9fee7f7195d55e3e5cc8b9a101c01e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Mon, 29 Jun 2026 18:41:48 +0200 Subject: [PATCH 03/13] CHANGELOG --- CHANGELOG.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17cc96aae9..b91fd86b86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # Changelog for v1.9 -Nothing, so far. +## v1.9.0-dev + +### Potential breaking changes + + * [Phoenix.Router] Phoenix will now group your routes per verb during compilation. This improves compilation times for large routers with no performance cost at runtime. However, this change implies that wildcard routes are always matched last, which changes the semantics of dead routes. If you had this code: + + match :*, "/foo" + get "/foo" + + The second route would never be matched but it will now be as part of this pull request. However, note that `get "/foo"` should not exist in the first place: it should be either removed or moved first. We recommend checking for any `match :*` in your router and moving them to the end of the file. ## v1.8 From d53648a91958bba4cf0062a5f596b646bdb32e94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Mon, 29 Jun 2026 21:23:38 +0200 Subject: [PATCH 04/13] Improve assertions --- test/phoenix/router/routing_test.exs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/phoenix/router/routing_test.exs b/test/phoenix/router/routing_test.exs index 060be17a63..859371ddd6 100644 --- a/test/phoenix/router/routing_test.exs +++ b/test/phoenix/router/routing_test.exs @@ -243,8 +243,11 @@ defmodule Phoenix.Router.RoutingTest do import ExUnit.Assertions, except: [trace: 3] match :*, "/foo/:bar", UserController, :index - get "/foo/:baz", UserController, :index + get "/foo/:baz", UserController, :show end + + conn = call(MatchOverlap, :get, "foo/example") + assert conn.resp_body == "users show" end) =~ "found route matching on \"/foo/:baz\" after match(:*, \"/foo/:bar\")" end end From 75bb3a339fc9e5f2f5dbc08c2892ab20177fa0d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Mon, 29 Jun 2026 21:23:51 +0200 Subject: [PATCH 05/13] Update CHANGELOG.md Co-authored-by: Steffen Deusch --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b91fd86b86..e970448f1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ match :*, "/foo" get "/foo" - The second route would never be matched but it will now be as part of this pull request. However, note that `get "/foo"` should not exist in the first place: it should be either removed or moved first. We recommend checking for any `match :*` in your router and moving them to the end of the file. + The second route would never be matched but it will now be as part of this change. However, note that `get "/foo"` should not exist in the first place: it should be either removed or moved first. We recommend checking for any `match :*` in your router and moving them to the end of the file. ## v1.8 From a3aee1a25378c5398e676297bb7bafaa862ef059 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Tue, 30 Jun 2026 13:29:46 +0200 Subject: [PATCH 06/13] Force all forwards and matches to be at the end --- CHANGELOG.md | 7 +- guides/routing.md | 2 + lib/phoenix/router.ex | 139 ++++++++++++++++++--------- test/phoenix/router/forward_test.exs | 14 +++ test/phoenix/router/routing_test.exs | 21 +++- 5 files changed, 126 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e970448f1f..9540deb6dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,7 @@ ### Potential breaking changes - * [Phoenix.Router] Phoenix will now group your routes per verb during compilation. This improves compilation times for large routers with no performance cost at runtime. However, this change implies that wildcard routes are always matched last, which changes the semantics of dead routes. If you had this code: - - match :*, "/foo" - get "/foo" - - The second route would never be matched but it will now be as part of this change. However, note that `get "/foo"` should not exist in the first place: it should be either removed or moved first. We recommend checking for any `match :*` in your router and moving them to the end of the file. + * [Phoenix.Router] Phoenix will now group your routes per verb during compilation when all `match :*` and `forward` routes are defined at the end of the router. This improves compilation times for large routers with no performance cost at runtime. If you have a route with an explicit verb after a `match :*` or `forward`, Phoenix will preserve the previous ordered matching semantics and emit a warning ## v1.8 diff --git a/guides/routing.md b/guides/routing.md index b7efc0b24f..f3e2a04f0a 100644 --- a/guides/routing.md +++ b/guides/routing.md @@ -613,6 +613,8 @@ end This means that all routes starting with `/jobs` will be sent to the `BackgroundJob.Plug` module. Inside the plug, you can match on subroutes, such as `/pending` and `/active` that shows the status of certain jobs. +Since forwards match all HTTP methods under their path, keep them at the end of your router, after your application routes. The same applies to any `match :*` routes. Phoenix will emit a compile-time warning if a route with an explicit verb is defined after a forward or `match :*`. + We can even mix the [`forward/4`](`Phoenix.Router.forward/4`) macro with pipelines. If we wanted to ensure that the user was authenticated and was an administrator in order to see the jobs page, we could use the following in our router. ```elixir diff --git a/lib/phoenix/router.ex b/lib/phoenix/router.ex index 848dd44846..463241a91d 100644 --- a/lib/phoenix/router.ex +++ b/lib/phoenix/router.ex @@ -487,22 +487,15 @@ defmodule Phoenix.Router do @doc false defmacro __before_compile__(env) do routes = env.module |> Module.get_attribute(:phoenix_routes) |> Enum.reverse() - routes_with_exprs = Enum.map(routes, &{&1, Route.exprs(&1)}) + {routes_with_exprs, warn_on_route_after_catch_all?} = build_route_exprs(routes, env) helpers = if Module.get_attribute(env.module, :phoenix_helpers) do Helpers.define(env, routes_with_exprs) end - # Group routes by verb making sure the ones that match all are handled last {matches, {pipelines, _}} = - routes_with_exprs - |> Enum.group_by(&elem(&1, 0).verb) - |> Map.pop(:*, []) - |> then(fn {match_routes_exprs, map} -> - Map.to_list(map) ++ [{:*, match_routes_exprs}] - end) - |> Enum.map_reduce({[], %{}}, &build_match_verb/2) + build_matches(routes_with_exprs, warn_on_route_after_catch_all?) routes_per_path = Enum.group_by(routes_with_exprs, &elem(&1, 1).path, &elem(&1, 0)) @@ -511,7 +504,7 @@ defmodule Phoenix.Router do routes_with_exprs |> Enum.map(&elem(&1, 1).path) |> Enum.uniq() - |> Enum.map(&build_verify(&1, routes_per_path, env)) + |> Enum.map(&build_verify(&1, routes_per_path)) verify_catch_all = quote generated: true do @@ -562,11 +555,51 @@ defmodule Phoenix.Router do end end - defp build_verify(path, routes_per_path, env) do + defp build_route_exprs(routes, env) do + {routes_with_exprs, state} = + Enum.map_reduce(routes, nil, fn route, catch_all -> + {{route, Route.exprs(route)}, warn_on_route_after_catch_all(route, catch_all, env)} + end) + + {routes_with_exprs, match?({:warned, _}, state)} + end + + defp warn_on_route_after_catch_all(route, catch_all, env) do + cond do + # TODO: error in future releases instead of warning + catch_all && !catch_all_route?(route) -> + IO.warn( + "found route #{inspect(route.path)} after #{catch_all_description(catch_all_route(catch_all))}. " <> + "Routes after match :* or forward will be matched before them in optimized routers, " <> + "so define all match :* and forward routes at the end of the router.", + Macro.Env.stacktrace(%{env | line: route.line}) + ) + + {:warned, catch_all_route(catch_all)} + + catch_all_route?(route) -> + catch_all || route + + true -> + catch_all + end + end + + defp catch_all_route({:warned, route}), do: route + defp catch_all_route(route), do: route + + defp catch_all_route?(%{kind: :forward}), do: true + defp catch_all_route?(%{verb: :*}), do: true + defp catch_all_route?(_route), do: false + + defp catch_all_description(%{kind: :forward, path: path}), do: "forward #{inspect(path)}" + defp catch_all_description(%{path: path}), do: "match :*, #{inspect(path)}" + + defp build_verify(path, routes_per_path) do routes = Map.get(routes_per_path, path) warn_on_verify? = Enum.all?(routes, & &1.warn_on_verify?) - case find_forward_and_check_status(routes, nil, nil, env) do + case Enum.find(routes, &(&1.kind == :forward)) do %{metadata: %{forward: forward}, plug: plug, plug_opts: plug_opts} -> quote generated: true do def __forward__(unquote(plug)) do @@ -588,41 +621,28 @@ defmodule Phoenix.Router do end end - defp find_forward_and_check_status( - [%{kind: kind, verb: verb} = route | routes], - forward, - star_route, - env - ) do - forward = - if kind == :forward and is_nil(forward) do - route - else - forward - end - - star_route = - cond do - star_route -> - IO.warn( - "found route matching on #{inspect(route.path)} after match(:*, #{inspect(star_route.path)})", - Macro.Env.stacktrace(%{env | line: route.line}) - ) - - star_route - - verb == :* -> - route + defp build_matches(routes_exprs, true) do + {matches, acc} = Enum.map_reduce(routes_exprs, {[], %{}}, &build_match/2) - true -> - nil + match_catch_all = + quote generated: true do + @doc false + def __match_route__(_verb, _path, _host) do + :error + end end - find_forward_and_check_status(routes, forward, star_route, env) + {[matches, match_catch_all], acc} end - defp find_forward_and_check_status([], forward, _star_route, _env) do - forward + defp build_matches(routes_exprs, false) do + routes_exprs + |> Enum.group_by(&elem(&1, 0).verb) + |> Map.pop(:*, []) + |> then(fn {match_routes_exprs, map} -> + Map.to_list(map) ++ [{:*, match_routes_exprs}] + end) + |> Enum.map_reduce({[], %{}}, &build_match_verb/2) end defp build_match_verb({:*, routes_exprs}, acc) do @@ -668,8 +688,26 @@ defmodule Phoenix.Router do {dispatch, acc} end + defp build_match({route, expr}, {acc_pipes, known_pipes}) do + verb_match = + case route.verb do + :* -> Macro.var(:_verb, nil) + verb -> verb |> to_string() |> String.upcase() + end + + {clauses, acc} = + build_match_path(:__match_route__, {route, expr}, {acc_pipes, known_pipes}, verb_match) + + {clauses, acc} + end + defp build_match_path(name, {route, expr}, {acc_pipes, known_pipes}) do + build_match_path(name, {route, expr}, {acc_pipes, known_pipes}, nil) + end + + defp build_match_path(name, {route, expr}, {acc_pipes, known_pipes}, verb_match) do {pipe_name, acc_pipes, known_pipes} = build_match_pipes(route, acc_pipes, known_pipes) + def_kind = if verb_match, do: :def, else: :defp %{ prepare: prepare, @@ -682,7 +720,9 @@ defmodule Phoenix.Router do clauses = for host <- hosts do quote line: route.line do - defp unquote(name)(unquote(path), unquote(host)) do + unquote(def_kind)( + unquote(name)(unquote_splicing(match_route_args(verb_match, path, host))) + ) do {unquote(build_metadata(route, path_params)), fn var!(conn, :conn), %{path_params: var!(path_params, :conn)} -> unquote(prepare) @@ -694,6 +734,9 @@ defmodule Phoenix.Router do {clauses, {acc_pipes, known_pipes}} end + defp match_route_args(nil, path, host), do: [path, host] + defp match_route_args(verb_match, path, host), do: [verb_match, path, host] + defp build_match_pipes(route, acc_pipes, known_pipes) do %{pipe_through: pipe_through} = route @@ -752,9 +795,9 @@ defmodule Phoenix.Router do Useful for defining routes not included in the built-in macros. The catch-all verb, `:*`, may also be used to match all HTTP methods. - However, keep in mind that routes with the catch-all verb are always - matched last. Therefore it is recommended that all `match :*` routes - are defined at the end of the file. + `match :*` routes should be defined at the end of the router, after all + routes with explicit verbs. Phoenix emits a compile-time warning if a route + with an explicit verb is defined after a `match :*` route. ## Options @@ -1288,6 +1331,10 @@ defmodule Phoenix.Router do The router pipelines will be invoked prior to forwarding the connection. + Because forwards match all HTTP methods, define them at the end of the + router, after all routes with explicit verbs. Phoenix emits a compile-time + warning if a route with an explicit verb is defined after a forward. + ## Examples scope "/", MyApp do diff --git a/test/phoenix/router/forward_test.exs b/test/phoenix/router/forward_test.exs index bced2bed7f..539a9b70a3 100644 --- a/test/phoenix/router/forward_test.exs +++ b/test/phoenix/router/forward_test.exs @@ -130,6 +130,20 @@ defmodule Phoenix.Router.ForwardTest do assert conn.private[ApiRouter] == [] end + test "warns on route after forward and preserves ordered matching" do + assert ExUnit.CaptureIO.capture_io(:stderr, fn -> + defmodule ForwardOverlap do + use Phoenix.Router + + forward "/admin", AdminDashboard + get "/admin/stats", Controller, :api_users + end + + conn = call(ForwardOverlap, :get, "/admin/stats") + assert conn.resp_body == "stats" + end) =~ "found route \"/admin/stats\" after forward \"/admin\"" + end + test "forwards raises if using the plug to arguments" do error_message = ~r/expect a module/ diff --git a/test/phoenix/router/routing_test.exs b/test/phoenix/router/routing_test.exs index 859371ddd6..5c4a93f0e1 100644 --- a/test/phoenix/router/routing_test.exs +++ b/test/phoenix/router/routing_test.exs @@ -44,7 +44,6 @@ defmodule Phoenix.Router.RoutingTest do get "/", UserController, :index, as: :users get "/users/top", UserController, :top, as: :top get "/users/:id", UserController, :show, as: :users, metadata: %{access: :user} - match :*, "/users/fallback", UserController, :any get "/spaced users/:id", UserController, :show get "/profiles/profile-:id", UserController, :show get "/route_that_crashes", UserController, :crash @@ -77,6 +76,7 @@ defmodule Phoenix.Router.RoutingTest do end get "/*path", UserController, :not_found + match :*, "/users/fallback", UserController, :any defp noop(conn, _), do: conn @@ -236,7 +236,7 @@ defmodule Phoenix.Router.RoutingTest do end describe "warnings" do - test "warns on duplicate route after :*" do + test "warns on route after :* and preserves ordered matching" do assert ExUnit.CaptureIO.capture_io(:stderr, fn -> defmodule MatchOverlap do use Phoenix.Router @@ -246,9 +246,20 @@ defmodule Phoenix.Router.RoutingTest do get "/foo/:baz", UserController, :show end - conn = call(MatchOverlap, :get, "foo/example") - assert conn.resp_body == "users show" - end) =~ "found route matching on \"/foo/:baz\" after match(:*, \"/foo/:bar\")" + conn = call(MatchOverlap, :get, "/foo/example") + assert conn.resp_body == "users index" + end) =~ "found route \"/foo/:baz\" after match :*, \"/foo/:bar\"" + end + + test "warns on any explicit route after :*" do + assert ExUnit.CaptureIO.capture_io(:stderr, fn -> + defmodule MatchBeforeUnrelatedRoute do + use Phoenix.Router + + match :*, "/any", UserController, :any + get "/pages/:id", UserController, :show + end + end) =~ "found route \"/pages/:id\" after match :*, \"/any\"" end end From 7a44864aad9e0ac1b9434e4834ce74dda08f9457 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Tue, 30 Jun 2026 13:46:05 +0200 Subject: [PATCH 07/13] CI --- .../test/support/code_generator_case.ex | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/integration_test/test/support/code_generator_case.ex b/integration_test/test/support/code_generator_case.ex index 12eef9bb02..3e9730987a 100644 --- a/integration_test/test/support/code_generator_case.ex +++ b/integration_test/test/support/code_generator_case.ex @@ -117,6 +117,36 @@ defmodule Phoenix.Integration.CodeGeneratorCase do def inject_before_final_end(code, code_to_inject) when is_binary(code) and is_binary(code_to_inject) do + inject_before_dev_routes_scope(code, code_to_inject) || + inject_before_final_end_fallback(code, code_to_inject) + end + + defp inject_before_dev_routes_scope(code, code_to_inject) do + case Regex.run( + ~r/\n if Application\.compile_env\(:[a-z][a-zA-Z0-9_]*, :dev_routes\) do/, + code, + return: :index + ) do + [{if_index, _}] -> + insert_index = + code + |> binary_part(0, if_index) + |> :binary.matches("\n # Enable ") + |> List.last() + |> case do + {comment_index, _} -> comment_index + nil -> if_index + end + + {before_dev_routes, dev_routes} = String.split_at(code, insert_index) + before_dev_routes <> code_to_inject <> dev_routes + + _ -> + nil + end + end + + defp inject_before_final_end_fallback(code, code_to_inject) do code |> String.trim_trailing() |> String.trim_trailing("end") From bb8f505896cc0e95a453f99aa5e6b33bb2763d9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Tue, 30 Jun 2026 14:53:46 +0200 Subject: [PATCH 08/13] CI --- .../test/support/code_generator_case.ex | 40 ++++--- lib/mix/tasks/phx.gen.auth/injector.ex | 48 ++++++-- test/mix/tasks/phx.gen.auth/injector_test.exs | 110 +++++++++++++++--- 3 files changed, 153 insertions(+), 45 deletions(-) diff --git a/integration_test/test/support/code_generator_case.ex b/integration_test/test/support/code_generator_case.ex index 3e9730987a..2de587127f 100644 --- a/integration_test/test/support/code_generator_case.ex +++ b/integration_test/test/support/code_generator_case.ex @@ -122,30 +122,34 @@ defmodule Phoenix.Integration.CodeGeneratorCase do end defp inject_before_dev_routes_scope(code, code_to_inject) do - case Regex.run( - ~r/\n if Application\.compile_env\(:[a-z][a-zA-Z0-9_]*, :dev_routes\) do/, - code, - return: :index - ) do - [{if_index, _}] -> - insert_index = - code - |> binary_part(0, if_index) - |> :binary.matches("\n # Enable ") - |> List.last() - |> case do - {comment_index, _} -> comment_index - nil -> if_index - end - - {before_dev_routes, dev_routes} = String.split_at(code, insert_index) - before_dev_routes <> code_to_inject <> dev_routes + case split_before_dev_routes(String.split(code, "\n"), []) do + {before_dev_routes, dev_routes} -> + join_injected(before_dev_routes, code_to_inject, dev_routes) _ -> nil end end + defp split_before_dev_routes([line | rest], acc) do + if String.contains?(line, "if Application.compile_env") and + String.contains?(line, ":dev_routes") do + {dev_route_comments, before_dev_routes} = + Enum.split_while(acc, &(String.trim_leading(&1) |> String.starts_with?("#"))) + + {Enum.reverse(before_dev_routes), Enum.reverse(dev_route_comments, [line | rest])} + else + split_before_dev_routes(rest, [line | acc]) + end + end + + defp split_before_dev_routes([], _acc), do: nil + + defp join_injected(before_dev_routes, code_to_inject, dev_routes) do + code_to_inject = code_to_inject |> String.trim("\n") |> String.split("\n") + Enum.join(before_dev_routes ++ code_to_inject ++ [""] ++ dev_routes, "\n") + end + defp inject_before_final_end_fallback(code, code_to_inject) do code |> String.trim_trailing() diff --git a/lib/mix/tasks/phx.gen.auth/injector.ex b/lib/mix/tasks/phx.gen.auth/injector.ex index 7629d21bdd..32b96ab4f8 100644 --- a/lib/mix/tasks/phx.gen.auth/injector.ex +++ b/lib/mix/tasks/phx.gen.auth/injector.ex @@ -268,17 +268,49 @@ defmodule Mix.Tasks.Phx.Gen.Auth.Injector do if String.contains?(code, code_to_inject) do :already_injected else - new_code = - code - |> String.trim_trailing() - |> String.trim_trailing("end") - |> Kernel.<>(code_to_inject) - |> Kernel.<>("end\n") - - {:ok, new_code} + {:ok, + inject_before_dev_routes_scope(code, code_to_inject) || + inject_before_final_end_fallback(code, code_to_inject)} end end + defp inject_before_dev_routes_scope(code, code_to_inject) do + case split_before_dev_routes(String.split(code, "\n"), []) do + {before_dev_routes, dev_routes} -> + join_injected(before_dev_routes, code_to_inject, dev_routes) + + _ -> + nil + end + end + + defp split_before_dev_routes([line | rest], acc) do + if String.contains?(line, "if Application.compile_env") and + String.contains?(line, ":dev_routes") do + {dev_route_comments, before_dev_routes} = + Enum.split_while(acc, &(String.trim_leading(&1) |> String.starts_with?("#"))) + + {Enum.reverse(before_dev_routes), Enum.reverse(dev_route_comments, [line | rest])} + else + split_before_dev_routes(rest, [line | acc]) + end + end + + defp split_before_dev_routes([], _acc), do: nil + + defp join_injected(before_dev_routes, code_to_inject, dev_routes) do + code_to_inject = code_to_inject |> String.trim("\n") |> String.split("\n") + Enum.join(before_dev_routes ++ code_to_inject ++ [""] ++ dev_routes, "\n") + end + + defp inject_before_final_end_fallback(code, code_to_inject) do + code + |> String.trim_trailing() + |> String.trim_trailing("end") + |> Kernel.<>(code_to_inject) + |> Kernel.<>("end\n") + end + @spec ensure_not_already_injected(String.t(), String.t()) :: :ok | :already_injected defp ensure_not_already_injected(file, inject) do if String.contains?(file, inject) do diff --git a/test/mix/tasks/phx.gen.auth/injector_test.exs b/test/mix/tasks/phx.gen.auth/injector_test.exs index c02e02576b..1fb93bc259 100644 --- a/test/mix/tasks/phx.gen.auth/injector_test.exs +++ b/test/mix/tasks/phx.gen.auth/injector_test.exs @@ -193,6 +193,47 @@ defmodule Mix.Tasks.Phx.Gen.Auth.InjectorTest do """ end + test "injects code before dev routes" do + existing_code = """ + defmodule MyApp.Router do + use MyApp, :router + + # Enable Swoosh mailbox preview in development. + if Application.compile_env(:my_app, :dev_routes) do + scope "/dev" do + forward "/mailbox", Plug.Swoosh.MailboxPreview + end + end + end + """ + + code_to_inject = """ + + scope "/", MyApp do + live "/users/settings", UserLive.Settings, :edit + end + """ + + assert {:ok, new_code} = Injector.inject_before_final_end(existing_code, code_to_inject) + + assert new_code == """ + defmodule MyApp.Router do + use MyApp, :router + + scope "/", MyApp do + live "/users/settings", UserLive.Settings, :edit + end + + # Enable Swoosh mailbox preview in development. + if Application.compile_env(:my_app, :dev_routes) do + scope "/dev" do + forward "/mailbox", Plug.Swoosh.MailboxPreview + end + end + end + """ + end + test "returns :already_injected when code has been injected" do existing_code = """ defmodule MyApp.Router do @@ -468,9 +509,14 @@ defmodule Mix.Tasks.Phx.Gen.Auth.InjectorTest do describe "router_plug_inject/2" do test "injects after :put_secure_browser_headers" do - schema = Schema.new("Accounts.User", "users", [], [no_scope: true]) + schema = Schema.new("Accounts.User", "users", [], no_scope: true) context = Context.new("Accounts", schema, []) - binding = [schema: schema, context: context, scope_config: %{scope: %{assign_key: :current_scope}}] + + binding = [ + schema: schema, + context: context, + scope_config: %{scope: %{assign_key: :current_scope}} + ] input = """ defmodule DemoWeb.Router do @@ -506,9 +552,14 @@ defmodule Mix.Tasks.Phx.Gen.Auth.InjectorTest do end test "injects after :put_secure_browser_headers even when it has additional options" do - schema = Schema.new("Accounts.User", "users", [], [no_scope: true]) + schema = Schema.new("Accounts.User", "users", [], no_scope: true) context = Context.new("Accounts", schema, []) - binding = [schema: schema, context: context, scope_config: %{scope: %{assign_key: :current_scope}}] + + binding = [ + schema: schema, + context: context, + scope_config: %{scope: %{assign_key: :current_scope}} + ] input = """ defmodule DemoWeb.Router do @@ -544,9 +595,14 @@ defmodule Mix.Tasks.Phx.Gen.Auth.InjectorTest do end test "respects windows line endings" do - schema = Schema.new("Accounts.User", "users", [], [no_scope: true]) + schema = Schema.new("Accounts.User", "users", [], no_scope: true) context = Context.new("Accounts", schema, []) - binding = [schema: schema, context: context, scope_config: %{scope: %{assign_key: :current_scope}}] + + binding = [ + schema: schema, + context: context, + scope_config: %{scope: %{assign_key: :current_scope}} + ] input = """ defmodule DemoWeb.Router do\r @@ -582,9 +638,14 @@ defmodule Mix.Tasks.Phx.Gen.Auth.InjectorTest do end test "errors when :put_secure_browser_headers_is_missing" do - schema = Schema.new("Accounts.User", "users", [], [no_scope: true]) + schema = Schema.new("Accounts.User", "users", [], no_scope: true) context = Context.new("Accounts", schema, []) - binding = [schema: schema, context: context, scope_config: %{scope: %{assign_key: :current_scope}}] + + binding = [ + schema: schema, + context: context, + scope_config: %{scope: %{assign_key: :current_scope}} + ] input = """ defmodule DemoWeb.Router do @@ -605,9 +666,14 @@ defmodule Mix.Tasks.Phx.Gen.Auth.InjectorTest do describe "router_plug_help_text/2" do test "returns a string with the expected help text" do - schema = Schema.new("Accounts.User", "users", [], [no_scope: true]) + schema = Schema.new("Accounts.User", "users", [], no_scope: true) context = Context.new("Accounts", schema, []) - binding = [schema: schema, context: context, scope_config: %{scope: %{assign_key: :current_scope}}] + + binding = [ + schema: schema, + context: context, + scope_config: %{scope: %{assign_key: :current_scope}} + ] file_path = Path.expand("foo.ex") @@ -624,9 +690,14 @@ defmodule Mix.Tasks.Phx.Gen.Auth.InjectorTest do end test "adheres to the --assign-key" do - schema = Schema.new("Accounts.User", "users", [], [no_scope: true]) + schema = Schema.new("Accounts.User", "users", [], no_scope: true) context = Context.new("Accounts", schema, []) - binding = [schema: schema, context: context, scope_config: %{scope: %{assign_key: :current_user_scope}}] + + binding = [ + schema: schema, + context: context, + scope_config: %{scope: %{assign_key: :current_user_scope}} + ] file_path = Path.expand("foo.ex") @@ -645,7 +716,7 @@ defmodule Mix.Tasks.Phx.Gen.Auth.InjectorTest do describe "app_layout_menu_inject/2" do test "injects user menu at the bottom of nav section when it exists" do - schema = Schema.new("Accounts.User", "users", [], [no_scope: true]) + schema = Schema.new("Accounts.User", "users", [], no_scope: true) binding = [schema: schema, scope_config: %{scope: %{assign_key: :current_scope}}] template = """ @@ -719,7 +790,7 @@ defmodule Mix.Tasks.Phx.Gen.Auth.InjectorTest do end test "injects user menu at the bottom of nav section when it exists with windows line endings" do - schema = Schema.new("Accounts.User", "users", [], [no_scope: true]) + schema = Schema.new("Accounts.User", "users", [], no_scope: true) binding = [schema: schema, scope_config: %{scope: %{assign_key: :current_scope}}] template = """ @@ -793,7 +864,7 @@ defmodule Mix.Tasks.Phx.Gen.Auth.InjectorTest do end test "injects render user_menu after the opening body tag" do - schema = Schema.new("Accounts.User", "users", [], [no_scope: true]) + schema = Schema.new("Accounts.User", "users", [], no_scope: true) binding = [schema: schema, scope_config: %{scope: %{assign_key: :current_scope}}] template = """ @@ -853,7 +924,7 @@ defmodule Mix.Tasks.Phx.Gen.Auth.InjectorTest do end test "works with windows line endings" do - schema = Schema.new("Accounts.User", "users", [], [no_scope: true]) + schema = Schema.new("Accounts.User", "users", [], no_scope: true) binding = [schema: schema, scope_config: %{scope: %{assign_key: :current_scope}}] template = """ @@ -913,8 +984,9 @@ defmodule Mix.Tasks.Phx.Gen.Auth.InjectorTest do end test "returns :already_injected when render is already found in file" do - schema = Schema.new("Accounts.User", "users", [], [no_scope: true]) + schema = Schema.new("Accounts.User", "users", [], no_scope: true) binding = [schema: schema, scope_config: %{scope: %{assign_key: :current_scope}}] + template = """ @@ -947,7 +1019,7 @@ defmodule Mix.Tasks.Phx.Gen.Auth.InjectorTest do end test "returns {:error, :unable_to_inject} when the body tag isn't found" do - schema = Schema.new("Accounts.User", "users", [], [no_scope: true]) + schema = Schema.new("Accounts.User", "users", [], no_scope: true) binding = [schema: schema, scope_config: %{scope: %{assign_key: :current_scope}}] assert {:error, :unable_to_inject} = Injector.app_layout_menu_inject(binding, "") end @@ -955,7 +1027,7 @@ defmodule Mix.Tasks.Phx.Gen.Auth.InjectorTest do describe "app_layout_menu_help_text/2" do test "returns a string with the expected help text" do - schema = Schema.new("Accounts.User", "users", [], [no_scope: true]) + schema = Schema.new("Accounts.User", "users", [], no_scope: true) binding = [schema: schema, scope_config: %{scope: %{assign_key: :current_scope}}] file_path = Path.expand("foo.ex") From 78fbf7a53861af071214c7428a5963f832cc89d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Tue, 30 Jun 2026 16:34:31 +0200 Subject: [PATCH 09/13] Apply suggestion from @josevalim --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9540deb6dd..679f1aff13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## v1.9.0-dev -### Potential breaking changes +### Deprecation * [Phoenix.Router] Phoenix will now group your routes per verb during compilation when all `match :*` and `forward` routes are defined at the end of the router. This improves compilation times for large routers with no performance cost at runtime. If you have a route with an explicit verb after a `match :*` or `forward`, Phoenix will preserve the previous ordered matching semantics and emit a warning From bf5bac50636a3d55cf4a924340218ec1355fe336 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Tue, 30 Jun 2026 16:41:28 +0200 Subject: [PATCH 10/13] Add group_by: :verb --- CHANGELOG.md | 4 +- guides/routing.md | 2 +- lib/phoenix/router.ex | 131 ++++++++++++++++---------- test/phoenix/router/forward_test.exs | 14 --- test/phoenix/router/group_by_test.exs | 109 +++++++++++++++++++++ test/phoenix/router/routing_test.exs | 30 +----- 6 files changed, 193 insertions(+), 97 deletions(-) create mode 100644 test/phoenix/router/group_by_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 679f1aff13..f47d596ef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,9 @@ ## v1.9.0-dev -### Deprecation +### Enhancements - * [Phoenix.Router] Phoenix will now group your routes per verb during compilation when all `match :*` and `forward` routes are defined at the end of the router. This improves compilation times for large routers with no performance cost at runtime. If you have a route with an explicit verb after a `match :*` or `forward`, Phoenix will preserve the previous ordered matching semantics and emit a warning + * [Phoenix.Router] Add `use Phoenix.Router, group_by: :verb` to group routes per verb during compilation. This can improve compilation times for large routers with no performance cost at runtime. When enabled, all `match :*` and `forward` routes must be defined at the end of the router, otherwise compilation fails with a list of violations ## v1.8 diff --git a/guides/routing.md b/guides/routing.md index f3e2a04f0a..c106b730af 100644 --- a/guides/routing.md +++ b/guides/routing.md @@ -613,7 +613,7 @@ end This means that all routes starting with `/jobs` will be sent to the `BackgroundJob.Plug` module. Inside the plug, you can match on subroutes, such as `/pending` and `/active` that shows the status of certain jobs. -Since forwards match all HTTP methods under their path, keep them at the end of your router, after your application routes. The same applies to any `match :*` routes. Phoenix will emit a compile-time warning if a route with an explicit verb is defined after a forward or `match :*`. +Since forwards match all HTTP methods under their path, keep them at the end of your router, after your application routes. The same applies to any `match :*` routes. This is required when a router is configured with `group_by: :verb`. We can even mix the [`forward/4`](`Phoenix.Router.forward/4`) macro with pipelines. If we wanted to ensure that the user was authenticated and was an administrator in order to see the jobs page, we could use the following in our router. diff --git a/lib/phoenix/router.ex b/lib/phoenix/router.ex index 463241a91d..950af9518b 100644 --- a/lib/phoenix/router.ex +++ b/lib/phoenix/router.ex @@ -142,6 +142,11 @@ defmodule Phoenix.Router do named helpers to help developers generate and keep their routes up to date. Helpers can be disabled by passing `helpers: false` to `use Phoenix.Router`. + Routers may also be configured with `group_by: :verb`, which groups route + clauses per HTTP verb during compilation. This can reduce compilation times + for large routers. When enabled, all `match :*` and `forward` routes must be + defined at the end of the router. + Helpers are automatically generated based on the controller name. For example, the route: @@ -296,9 +301,11 @@ defmodule Phoenix.Router do defp prelude(opts) do quote do + opts = unquote(opts) Module.register_attribute(__MODULE__, :phoenix_routes, accumulate: true) # TODO: Require :helpers to be explicit given - @phoenix_helpers Keyword.get(unquote(opts), :helpers, true) + @phoenix_helpers Keyword.get(opts, :helpers, true) + @phoenix_router_group_by Keyword.get(opts, :group_by, nil) import Phoenix.Router @@ -487,7 +494,7 @@ defmodule Phoenix.Router do @doc false defmacro __before_compile__(env) do routes = env.module |> Module.get_attribute(:phoenix_routes) |> Enum.reverse() - {routes_with_exprs, warn_on_route_after_catch_all?} = build_route_exprs(routes, env) + routes_with_exprs = Enum.map(routes, &{&1, Route.exprs(&1)}) helpers = if Module.get_attribute(env.module, :phoenix_helpers) do @@ -495,7 +502,11 @@ defmodule Phoenix.Router do end {matches, {pipelines, _}} = - build_matches(routes_with_exprs, warn_on_route_after_catch_all?) + build_matches( + routes_with_exprs, + Module.get_attribute(env.module, :phoenix_router_group_by), + env + ) routes_per_path = Enum.group_by(routes_with_exprs, &elem(&1, 1).path, &elem(&1, 0)) @@ -555,46 +566,6 @@ defmodule Phoenix.Router do end end - defp build_route_exprs(routes, env) do - {routes_with_exprs, state} = - Enum.map_reduce(routes, nil, fn route, catch_all -> - {{route, Route.exprs(route)}, warn_on_route_after_catch_all(route, catch_all, env)} - end) - - {routes_with_exprs, match?({:warned, _}, state)} - end - - defp warn_on_route_after_catch_all(route, catch_all, env) do - cond do - # TODO: error in future releases instead of warning - catch_all && !catch_all_route?(route) -> - IO.warn( - "found route #{inspect(route.path)} after #{catch_all_description(catch_all_route(catch_all))}. " <> - "Routes after match :* or forward will be matched before them in optimized routers, " <> - "so define all match :* and forward routes at the end of the router.", - Macro.Env.stacktrace(%{env | line: route.line}) - ) - - {:warned, catch_all_route(catch_all)} - - catch_all_route?(route) -> - catch_all || route - - true -> - catch_all - end - end - - defp catch_all_route({:warned, route}), do: route - defp catch_all_route(route), do: route - - defp catch_all_route?(%{kind: :forward}), do: true - defp catch_all_route?(%{verb: :*}), do: true - defp catch_all_route?(_route), do: false - - defp catch_all_description(%{kind: :forward, path: path}), do: "forward #{inspect(path)}" - defp catch_all_description(%{path: path}), do: "match :*, #{inspect(path)}" - defp build_verify(path, routes_per_path) do routes = Map.get(routes_per_path, path) warn_on_verify? = Enum.all?(routes, & &1.warn_on_verify?) @@ -621,7 +592,7 @@ defmodule Phoenix.Router do end end - defp build_matches(routes_exprs, true) do + defp build_matches(routes_exprs, nil, _env) do {matches, acc} = Enum.map_reduce(routes_exprs, {[], %{}}, &build_match/2) match_catch_all = @@ -635,7 +606,9 @@ defmodule Phoenix.Router do {[matches, match_catch_all], acc} end - defp build_matches(routes_exprs, false) do + defp build_matches(routes_exprs, :verb, env) do + validate_group_by_verb!(routes_exprs, env) + routes_exprs |> Enum.group_by(&elem(&1, 0).verb) |> Map.pop(:*, []) @@ -645,6 +618,64 @@ defmodule Phoenix.Router do |> Enum.map_reduce({[], %{}}, &build_match_verb/2) end + defp build_matches(_routes_exprs, group_by, _env) do + raise ArgumentError, + "expected :group_by to be :verb or nil, got: #{inspect(group_by)}" + end + + defp validate_group_by_verb!(routes_exprs, env) do + routes = Enum.map(routes_exprs, &elem(&1, 0)) + + case routes_after_catch_all(routes, nil, []) do + [] -> + :ok + + violations -> + [{route, _catch_all} | _] = violations + + raise CompileError, + file: env.file, + line: route.line, + description: group_by_verb_error(violations) + end + end + + defp routes_after_catch_all([route | routes], catch_all, acc) do + cond do + catch_all && !catch_all_route?(route) -> + routes_after_catch_all(routes, catch_all, [{route, catch_all} | acc]) + + catch_all_route?(route) -> + routes_after_catch_all(routes, catch_all || route, acc) + + true -> + routes_after_catch_all(routes, catch_all, acc) + end + end + + defp routes_after_catch_all([], _catch_all, acc), do: Enum.reverse(acc) + + defp catch_all_route?(%{kind: :forward}), do: true + defp catch_all_route?(%{verb: :*}), do: true + defp catch_all_route?(_route), do: false + + defp catch_all_description(%{kind: :forward, path: path}), do: "forward #{inspect(path)}" + defp catch_all_description(%{path: path}), do: "match :*, #{inspect(path)}" + + defp group_by_verb_error(violations) do + routes = + Enum.map_join(violations, "\n", fn {route, catch_all} -> + " * #{inspect(route.path)} after #{catch_all_description(catch_all)}" + end) + + """ + cannot compile router with group_by: :verb because routes were found after a match :* or forward. + Define all match :* and forward routes at the end of the router. + + #{routes} + """ + end + defp build_match_verb({:*, routes_exprs}, acc) do name = :__match_route_catch_all__ {clauses, acc} = Enum.map_reduce(routes_exprs, acc, &build_match_path(name, &1, &2)) @@ -795,9 +826,8 @@ defmodule Phoenix.Router do Useful for defining routes not included in the built-in macros. The catch-all verb, `:*`, may also be used to match all HTTP methods. - `match :*` routes should be defined at the end of the router, after all - routes with explicit verbs. Phoenix emits a compile-time warning if a route - with an explicit verb is defined after a `match :*` route. + If the router is configured with `group_by: :verb`, all `match :*` routes + must be defined at the end of the router, after all routes with explicit verbs. ## Options @@ -1331,9 +1361,8 @@ defmodule Phoenix.Router do The router pipelines will be invoked prior to forwarding the connection. - Because forwards match all HTTP methods, define them at the end of the - router, after all routes with explicit verbs. Phoenix emits a compile-time - warning if a route with an explicit verb is defined after a forward. + If the router is configured with `group_by: :verb`, all forwards must be + defined at the end of the router, after all routes with explicit verbs. ## Examples diff --git a/test/phoenix/router/forward_test.exs b/test/phoenix/router/forward_test.exs index 539a9b70a3..bced2bed7f 100644 --- a/test/phoenix/router/forward_test.exs +++ b/test/phoenix/router/forward_test.exs @@ -130,20 +130,6 @@ defmodule Phoenix.Router.ForwardTest do assert conn.private[ApiRouter] == [] end - test "warns on route after forward and preserves ordered matching" do - assert ExUnit.CaptureIO.capture_io(:stderr, fn -> - defmodule ForwardOverlap do - use Phoenix.Router - - forward "/admin", AdminDashboard - get "/admin/stats", Controller, :api_users - end - - conn = call(ForwardOverlap, :get, "/admin/stats") - assert conn.resp_body == "stats" - end) =~ "found route \"/admin/stats\" after forward \"/admin\"" - end - test "forwards raises if using the plug to arguments" do error_message = ~r/expect a module/ diff --git a/test/phoenix/router/group_by_test.exs b/test/phoenix/router/group_by_test.exs new file mode 100644 index 0000000000..934de8e96e --- /dev/null +++ b/test/phoenix/router/group_by_test.exs @@ -0,0 +1,109 @@ +defmodule Phoenix.Router.GroupByTest do + use ExUnit.Case, async: true + use RouterHelper + + defmodule Controller do + use Phoenix.Controller, formats: [] + + def index(conn, _params), do: text(conn, "index") + def show(conn, _params), do: text(conn, "show #{conn.params["id"]}") + def files(conn, _params), do: text(conn, Enum.join(conn.params["path"], "/")) + def host(conn, _params), do: text(conn, "host #{conn.host}") + def create(conn, _params), do: text(conn, "create") + def any(conn, _params), do: text(conn, "any") + end + + defmodule Router do + use Phoenix.Router, group_by: :verb + + get "/", Controller, :index, metadata: %{page: :index} + post "/", Controller, :create, metadata: %{page: :create} + get "/users/:id", Controller, :show, metadata: %{page: :show} + get "/files/*path", Controller, :files, metadata: %{page: :files} + + scope "/", host: "api." do + get "/host", Controller, :host, metadata: %{page: :host} + end + + match :*, "/any", Controller, :any, metadata: %{page: :any} + end + + test "dispatches static, dynamic, glob, host, and catch-all verb routes" do + assert call(Router, :get, "/").resp_body == "index" + assert call(Router, :post, "/").resp_body == "create" + assert call(Router, :get, "/users/123").resp_body == "show 123" + assert call(Router, :get, "/files/foo/bar").resp_body == "foo/bar" + assert call(Router, :post, "/any").resp_body == "any" + + conn = + :get + |> conn("/host") + |> Map.put(:host, "api.example.com") + |> Router.call(Router.init([])) + + assert conn.resp_body == "host api.example.com" + end + + test "route_info returns metadata and path params" do + assert Phoenix.Router.route_info(Router, "GET", "/users/123", nil) == %{ + log: :debug, + page: :show, + path_params: %{"id" => "123"}, + pipe_through: [], + plug: Controller, + plug_opts: :show, + route: "/users/:id" + } + + assert Phoenix.Router.route_info(Router, "GET", "/files/foo/bar", nil) == %{ + log: :debug, + page: :files, + path_params: %{"path" => ["foo", "bar"]}, + pipe_through: [], + plug: Controller, + plug_opts: :files, + route: "/files/*path" + } + + assert Phoenix.Router.route_info(Router, "GET", "/host", "api.example.com") == %{ + log: :debug, + page: :host, + path_params: %{}, + pipe_through: [], + plug: Controller, + plug_opts: :host, + route: "/host" + } + end + + test "raises when explicit routes are defined after match :* or forward" do + match_error = + assert_raise CompileError, fn -> + defmodule InvalidMatchRouter do + use Phoenix.Router, group_by: :verb + + match :*, "/any", Controller, :any + get "/users/:id", Controller, :show + post "/files/*path", Controller, :files + end + end + + assert Exception.message(match_error) =~ + "cannot compile router with group_by: :verb because routes were found after a match :* or forward" + + assert Exception.message(match_error) =~ ~s|"/users/:id" after match :*, "/any"| + assert Exception.message(match_error) =~ ~s|"/files/*path" after match :*, "/any"| + + forward_error = + assert_raise CompileError, fn -> + defmodule InvalidForwardRouter do + use Phoenix.Router, group_by: :verb + + forward "/admin", Router + get "/admin/stats", Controller, :index + end + end + + assert Exception.message(forward_error) =~ ~s|"/admin/stats" after forward "/admin"| + end +end diff --git a/test/phoenix/router/routing_test.exs b/test/phoenix/router/routing_test.exs index 5c4a93f0e1..238645a493 100644 --- a/test/phoenix/router/routing_test.exs +++ b/test/phoenix/router/routing_test.exs @@ -44,6 +44,7 @@ defmodule Phoenix.Router.RoutingTest do get "/", UserController, :index, as: :users get "/users/top", UserController, :top, as: :top get "/users/:id", UserController, :show, as: :users, metadata: %{access: :user} + match :*, "/users/fallback", UserController, :any get "/spaced users/:id", UserController, :show get "/profiles/profile-:id", UserController, :show get "/route_that_crashes", UserController, :crash @@ -76,7 +77,6 @@ defmodule Phoenix.Router.RoutingTest do end get "/*path", UserController, :not_found - match :*, "/users/fallback", UserController, :any defp noop(conn, _), do: conn @@ -235,34 +235,6 @@ defmodule Phoenix.Router.RoutingTest do assert conn.path_params["id"] == "123" end - describe "warnings" do - test "warns on route after :* and preserves ordered matching" do - assert ExUnit.CaptureIO.capture_io(:stderr, fn -> - defmodule MatchOverlap do - use Phoenix.Router - import ExUnit.Assertions, except: [trace: 3] - - match :*, "/foo/:bar", UserController, :index - get "/foo/:baz", UserController, :show - end - - conn = call(MatchOverlap, :get, "/foo/example") - assert conn.resp_body == "users index" - end) =~ "found route \"/foo/:baz\" after match :*, \"/foo/:bar\"" - end - - test "warns on any explicit route after :*" do - assert ExUnit.CaptureIO.capture_io(:stderr, fn -> - defmodule MatchBeforeUnrelatedRoute do - use Phoenix.Router - - match :*, "/any", UserController, :any - get "/pages/:id", UserController, :show - end - end) =~ "found route \"/pages/:id\" after match :*, \"/any\"" - end - end - describe "logging" do setup do Logger.delete_process_level(self()) From 909b6ef7ea515c95f6690763286f58a09e1d6f42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Tue, 30 Jun 2026 16:49:39 +0200 Subject: [PATCH 11/13] Updates --- guides/routing.md | 2 - .../test/support/code_generator_case.ex | 34 -------------- lib/phoenix/router.ex | 46 +++++++++++-------- 3 files changed, 26 insertions(+), 56 deletions(-) diff --git a/guides/routing.md b/guides/routing.md index c106b730af..b7efc0b24f 100644 --- a/guides/routing.md +++ b/guides/routing.md @@ -613,8 +613,6 @@ end This means that all routes starting with `/jobs` will be sent to the `BackgroundJob.Plug` module. Inside the plug, you can match on subroutes, such as `/pending` and `/active` that shows the status of certain jobs. -Since forwards match all HTTP methods under their path, keep them at the end of your router, after your application routes. The same applies to any `match :*` routes. This is required when a router is configured with `group_by: :verb`. - We can even mix the [`forward/4`](`Phoenix.Router.forward/4`) macro with pipelines. If we wanted to ensure that the user was authenticated and was an administrator in order to see the jobs page, we could use the following in our router. ```elixir diff --git a/integration_test/test/support/code_generator_case.ex b/integration_test/test/support/code_generator_case.ex index 2de587127f..12eef9bb02 100644 --- a/integration_test/test/support/code_generator_case.ex +++ b/integration_test/test/support/code_generator_case.ex @@ -117,40 +117,6 @@ defmodule Phoenix.Integration.CodeGeneratorCase do def inject_before_final_end(code, code_to_inject) when is_binary(code) and is_binary(code_to_inject) do - inject_before_dev_routes_scope(code, code_to_inject) || - inject_before_final_end_fallback(code, code_to_inject) - end - - defp inject_before_dev_routes_scope(code, code_to_inject) do - case split_before_dev_routes(String.split(code, "\n"), []) do - {before_dev_routes, dev_routes} -> - join_injected(before_dev_routes, code_to_inject, dev_routes) - - _ -> - nil - end - end - - defp split_before_dev_routes([line | rest], acc) do - if String.contains?(line, "if Application.compile_env") and - String.contains?(line, ":dev_routes") do - {dev_route_comments, before_dev_routes} = - Enum.split_while(acc, &(String.trim_leading(&1) |> String.starts_with?("#"))) - - {Enum.reverse(before_dev_routes), Enum.reverse(dev_route_comments, [line | rest])} - else - split_before_dev_routes(rest, [line | acc]) - end - end - - defp split_before_dev_routes([], _acc), do: nil - - defp join_injected(before_dev_routes, code_to_inject, dev_routes) do - code_to_inject = code_to_inject |> String.trim("\n") |> String.split("\n") - Enum.join(before_dev_routes ++ code_to_inject ++ [""] ++ dev_routes, "\n") - end - - defp inject_before_final_end_fallback(code, code_to_inject) do code |> String.trim_trailing() |> String.trim_trailing("end") diff --git a/lib/phoenix/router.ex b/lib/phoenix/router.ex index 950af9518b..6921582238 100644 --- a/lib/phoenix/router.ex +++ b/lib/phoenix/router.ex @@ -45,6 +45,16 @@ defmodule Phoenix.Router do Phoenix's router is extremely efficient, as it relies on Elixir pattern matching for matching routes and serving requests. + ## Options + + * `:helpers` - whether to generate route helpers. Defaults to `true`. + Helpers are deprecated, see the "Helpers" section below for more information + + * `:group_by` - when set to `:verb`, Phoenix groups route clauses per HTTP + verb during compilation. This can reduce compilation times for large + routers. When enabled, all `match :*` and `forward` routes must be defined + at the end of the router + ## Routing `get/3`, `post/3`, `put/3`, and other macros named after HTTP verbs are used @@ -142,11 +152,6 @@ defmodule Phoenix.Router do named helpers to help developers generate and keep their routes up to date. Helpers can be disabled by passing `helpers: false` to `use Phoenix.Router`. - Routers may also be configured with `group_by: :verb`, which groups route - clauses per HTTP verb during compilation. This can reduce compilation times - for large routers. When enabled, all `match :*` and `forward` routes must be - defined at the end of the router. - Helpers are automatically generated based on the controller name. For example, the route: @@ -678,7 +683,9 @@ defmodule Phoenix.Router do defp build_match_verb({:*, routes_exprs}, acc) do name = :__match_route_catch_all__ - {clauses, acc} = Enum.map_reduce(routes_exprs, acc, &build_match_path(name, &1, &2)) + + {clauses, acc} = + Enum.map_reduce(routes_exprs, acc, &build_match_path(:defp, name, [], &1, &2)) dispatch = quote generated: true do @@ -701,7 +708,8 @@ defmodule Phoenix.Router do pattern = verb |> to_string() |> String.upcase() name = :"__match_route_#{verb}__" - {clauses, acc} = Enum.map_reduce(routes_exprs, acc, &build_match_path(name, &1, &2)) + {clauses, acc} = + Enum.map_reduce(routes_exprs, acc, &build_match_path(:defp, name, [], &1, &2)) dispatch = quote generated: true do @@ -727,18 +735,19 @@ defmodule Phoenix.Router do end {clauses, acc} = - build_match_path(:__match_route__, {route, expr}, {acc_pipes, known_pipes}, verb_match) + build_match_path( + :def, + :__match_route__, + [verb_match], + {route, expr}, + {acc_pipes, known_pipes} + ) {clauses, acc} end - defp build_match_path(name, {route, expr}, {acc_pipes, known_pipes}) do - build_match_path(name, {route, expr}, {acc_pipes, known_pipes}, nil) - end - - defp build_match_path(name, {route, expr}, {acc_pipes, known_pipes}, verb_match) do + defp build_match_path(kind, name, prefix, {route, expr}, {acc_pipes, known_pipes}) do {pipe_name, acc_pipes, known_pipes} = build_match_pipes(route, acc_pipes, known_pipes) - def_kind = if verb_match, do: :def, else: :defp %{ prepare: prepare, @@ -750,10 +759,10 @@ defmodule Phoenix.Router do clauses = for host <- hosts do + args = prefix ++ [path, host] + quote line: route.line do - unquote(def_kind)( - unquote(name)(unquote_splicing(match_route_args(verb_match, path, host))) - ) do + unquote(kind)(unquote(name)(unquote_splicing(args))) do {unquote(build_metadata(route, path_params)), fn var!(conn, :conn), %{path_params: var!(path_params, :conn)} -> unquote(prepare) @@ -765,9 +774,6 @@ defmodule Phoenix.Router do {clauses, {acc_pipes, known_pipes}} end - defp match_route_args(nil, path, host), do: [path, host] - defp match_route_args(verb_match, path, host), do: [verb_match, path, host] - defp build_match_pipes(route, acc_pipes, known_pipes) do %{pipe_through: pipe_through} = route From 2cf01939d57315fa7d3e8ed30befe87485a039f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Tue, 30 Jun 2026 17:07:20 +0200 Subject: [PATCH 12/13] Apply suggestion from @josevalim --- CHANGELOG.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f47d596ef9..17cc96aae9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,6 @@ # Changelog for v1.9 -## v1.9.0-dev - -### Enhancements - - * [Phoenix.Router] Add `use Phoenix.Router, group_by: :verb` to group routes per verb during compilation. This can improve compilation times for large routers with no performance cost at runtime. When enabled, all `match :*` and `forward` routes must be defined at the end of the router, otherwise compilation fails with a list of violations +Nothing, so far. ## v1.8 From d4a22535f38c9e95cb34b8226cbeb2cc7b20002a Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Tue, 30 Jun 2026 17:39:16 +0200 Subject: [PATCH 13/13] add back any test --- test/phoenix/router/routing_test.exs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/phoenix/router/routing_test.exs b/test/phoenix/router/routing_test.exs index 238645a493..0818d40ab8 100644 --- a/test/phoenix/router/routing_test.exs +++ b/test/phoenix/router/routing_test.exs @@ -57,6 +57,7 @@ defmodule Phoenix.Router.RoutingTest do options "/options", UserController, :options connect "/connect", UserController, :connect match :move, "/move", UserController, :move + match :*, "/any", UserController, :any scope log: :info do pipe_through :noop @@ -223,6 +224,18 @@ defmodule Phoenix.Router.RoutingTest do assert conn.resp_body == "users move" end + test "any verb matches" do + conn = call(Router, :get, "/any") + assert conn.method == "GET" + assert conn.status == 200 + assert conn.resp_body == "users any" + + conn = call(Router, :put, "/any") + assert conn.method == "PUT" + assert conn.status == 200 + assert conn.resp_body == "users any" + end + test "different verbs with similar paths" do conn = call(Router, :post, "/users/fallback") assert conn.status == 200