Repository: phoenixframework/phoenix
Pull request #6274 (2025-05-30 15:11:32)

Include maps in the verified routes section of the routing guide
The [verified routes](https://hexdocs.pm/phoenix/Phoenix.VerifiedRoutes.html) documentation contains "Or as a keyword list or map of values:" whereas the [routing](https://hexdocs.pm/phoenix/routing.html#more-on-verified-routes) documentation only specifies either a query string or "dictionary of key-value pairs".

The intent of this PR is to clarify what is acceptable as a query parameter and reflect what is documented in the verified routes guide.


Patch:
diff --git a/guides/deployment/releases.md b/guides/deployment/releases.md
index ba3399872..84272ea41 100644
--- a/guides/deployment/releases.md
+++ b/guides/deployment/releases.md
@@ -140,7 +140,7 @@ And that's it! If you peek inside the `migrate` script, you'll see it wraps exac
 
 You can use the same approach used for migrations to create any custom command to run in production. The idea is that each command invokes `load_app`, which calls `Application.ensure_loaded/1` to load the current application without starting it.
 
-However, some commands may need to start the whole application. In such cases, `Application.ensure_all_started/1` must be used instead of `Application.load/1`. Keep in mind starting the application will all processes in its supervision tree, including the Phoenix endpoint. This can be circumvented by changing your supervision tree to not start certain children under certain conditions. For example, in the release commands file you could do:
+However, some commands may need to start the whole application. In such cases, `Application.ensure_all_started/1` must be used instead of `Application.load/1`. Keep in mind starting the application will start all processes in its supervision tree, including the Phoenix endpoint. This can be circumvented by changing your supervision tree to not start certain children under certain conditions. For example, in the release commands file you could do:
 
 ```elixir
 defp start_app do
diff --git a/guides/real_time/channels.md b/guides/real_time/channels.md
index eaca2fbd1..9163ee67c 100644
--- a/guides/real_time/channels.md
+++ b/guides/real_time/channels.md
@@ -384,7 +384,7 @@ That's all there is to our basic chat app. Fire up multiple browser tabs and you
 
 ## Using Token Authentication
 
-When we connect, we'll often need to authenticate the client. Fortunately, this is a 4-step process with [Phoenix.Token](https://hexdocs.pm/phoenix/Phoenix.Token.html).
+When we connect, we'll often need to authenticate the client. Fortunately, this is a 5-step process with [Phoenix.Token](https://hexdocs.pm/phoenix/Phoenix.Token.html).
 
 ### Step 1 - Enable the `auth_token` functionality in the socket
 
diff --git a/guides/routing.md b/guides/routing.md
index 6291d1291..3f7693ae0 100644
--- a/guides/routing.md
+++ b/guides/routing.md
@@ -198,7 +198,7 @@ Using `~p` for route paths ensures our application paths and URLs stay up to dat
 
 ### More on verified routes
 
-What about paths with query strings? You can either add query string key values directly, or provide a dictionary of key-value pairs, for example:
+What about paths with query strings? You can add query string key values directly, as a keyword list or map of values, for example:
 
 ```elixir
 ~p"/users/17?admin=true&active=false"
@@ -206,6 +206,9 @@ What about paths with query strings? You can either add query string key values
 
 ~p"/users/17?#{[admin: true]}"
 "/users/17?admin=true"
+
+~p"/users/17?#{%{admin: true}}"
+"/users/17?admin=true"
 ```
 
 What if we need a full URL instead of a path? Just wrap your path with a call to `Phoenix.VerifiedRoutes.url/1`, which is imported everywhere that `~p` is available:
diff --git a/installer/templates/phx_web/components/core_components.ex b/installer/templates/phx_web/components/core_components.ex
index bb915097e..6e6bed30f 100644
--- a/installer/templates/phx_web/components/core_components.ex
+++ b/installer/templates/phx_web/components/core_components.ex
@@ -380,7 +380,7 @@ defmodule <%= @web_namespace %>.CoreComponents do
     ~H"""
     <ul class="list">
       <li :for={item <- @item} class="list-row">
-        <div>
+        <div class="list-col-grow">
           <div class="font-bold">{item.title}</div>
           <div>{render_slot(item)}</div>
         </div>
diff --git a/installer/templates/phx_web/components/layouts.ex b/installer/templates/phx_web/components/layouts.ex
index 9f284b715..ff4533b3d 100644
--- a/installer/templates/phx_web/components/layouts.ex
+++ b/installer/templates/phx_web/components/layouts.ex
@@ -18,8 +18,8 @@ defmodule <%= @web_namespace %>.Layouts do
 
       <Layouts.app flash={@flash}>
         <h1>Content</h1>
-      </Layout.app>
-      
+      </Layouts.app>
+
   """
   attr :flash, :map, required: true, doc: "the map of flash messages"
 
diff --git a/lib/phoenix.ex b/lib/phoenix.ex
index 9d13e28cd..9a4d5b7ac 100644
--- a/lib/phoenix.ex
+++ b/lib/phoenix.ex
@@ -18,6 +18,10 @@ defmodule Phoenix do
       :erlang.system_flag(:backtrace_depth, stacktrace_depth)
     end
 
+    if filter = Application.get_env(:phoenix, :filter_parameters) do
+      Application.put_env(:phoenix, :filter_parameters, Phoenix.Logger.compile_filter(filter))
+    end
+
     if Application.fetch_env!(:phoenix, :logger) do
       Phoenix.Logger.install()
     end
@@ -62,11 +66,11 @@ defmodule Phoenix do
     configured_lib = Application.get_env(:phoenix, :json_library)
 
     if configured_lib && not Code.ensure_loaded?(configured_lib) do
-      IO.warn """
+      IO.warn("""
       found #{inspect(configured_lib)} in your application configuration
       for Phoenix JSON encoding, but module #{inspect(configured_lib)} is not available.
       Ensure #{inspect(configured_lib)} is listed as a dependency in mix.exs.
-      """
+      """)
     end
   end
 end
diff --git a/lib/phoenix/logger.ex b/lib/phoenix/logger.ex
index f46788736..f561b7c50 100644
--- a/lib/phoenix/logger.ex
+++ b/lib/phoenix/logger.ex
@@ -161,48 +161,71 @@ defmodule Phoenix.Logger do
   end
 
   @doc false
-  def filter_values(values, params \\ Application.get_env(:phoenix, :filter_parameters, []))
-  def filter_values(values, {:discard, params}), do: discard_values(values, params)
-  def filter_values(values, {:keep, params}), do: keep_values(values, params)
-  def filter_values(values, params), do: discard_values(values, params)
+  def compile_filter({:compiled, _key, _value} = filter), do: filter
+  def compile_filter({:discard, params}), do: compile_discard(params)
+  def compile_filter({:keep, params}), do: {:keep, params}
+  def compile_filter(params), do: compile_discard(params)
 
-  defp discard_values(%{__struct__: mod} = struct, _params) when is_atom(mod) do
+  defp compile_discard([]) do
+    {:compiled, [], []}
+  end
+
+  defp compile_discard(params) when is_list(params) or is_binary(params) do
+    key_match = :binary.compile_pattern(params)
+    value_match = params |> List.wrap() |> Enum.map(&(&1 <> "=")) |> :binary.compile_pattern()
+    {:compiled, key_match, value_match}
+  end
+
+  @doc false
+  def filter_values(values, filter \\ Application.get_env(:phoenix, :filter_parameters, [])) do
+    case compile_filter(filter) do
+      {:compiled, key_match, value_match} -> discard_values(values, key_match, value_match)
+      {:keep, match} -> keep_values(values, match)
+    end
+  end
+
+  defp discard_values(%{__struct__: mod} = struct, _key_match, _value_match) when is_atom(mod) do
     struct
   end
 
-  defp discard_values(%{} = map, params) do
+  defp discard_values(%{} = map, key_match, value_match) do
     Enum.into(map, %{}, fn {k, v} ->
-      if is_binary(k) and String.contains?(k, params) do
-        {k, "[FILTERED]"}
-      else
-        {k, discard_values(v, params)}
+      cond do
+        is_binary(k) and String.contains?(k, key_match) ->
+          {k, "[FILTERED]"}
+
+        is_binary(v) and String.contains?(v, value_match) ->
+          {k, "[FILTERED]"}
+
+        true ->
+          {k, discard_values(v, key_match, value_match)}
       end
     end)
   end
 
-  defp discard_values([_ | _] = list, params) do
-    Enum.map(list, &discard_values(&1, params))
+  defp discard_values([_ | _] = list, key_match, value_match) do
+    Enum.map(list, &discard_values(&1, key_match, value_match))
   end
 
-  defp discard_values(other, _params), do: other
+  defp discard_values(other, _key_match, _value_match), do: other
 
-  defp keep_values(%{__struct__: mod}, _params) when is_atom(mod), do: "[FILTERED]"
+  defp keep_values(%{__struct__: mod}, _match) when is_atom(mod), do: "[FILTERED]"
 
-  defp keep_values(%{} = map, params) do
+  defp keep_values(%{} = map, match) do
     Enum.into(map, %{}, fn {k, v} ->
-      if is_binary(k) and k in params do
-        {k, discard_values(v, [])}
+      if is_binary(k) and k in match do
+        {k, v}
       else
-        {k, keep_values(v, params)}
+        {k, keep_values(v, match)}
       end
     end)
   end
 
-  defp keep_values([_ | _] = list, params) do
-    Enum.map(list, &keep_values(&1, params))
+  defp keep_values([_ | _] = list, match) do
+    Enum.map(list, &keep_values(&1, match))
   end
 
-  defp keep_values(_other, _params), do: "[FILTERED]"
+  defp keep_values(_other, _match), do: "[FILTERED]"
 
   defp log_level(nil, _conn), do: :info
   defp log_level(level, _conn) when is_atom(level), do: level
@@ -348,7 +371,12 @@ defmodule Phoenix.Logger do
   @doc false
   def phoenix_socket_drain(_, _, %{log: false}, _), do: :ok
 
-  def phoenix_socket_drain(_, %{count: count, total: total, index: index, rounds: rounds}, %{log: level} = meta, _) do
+  def phoenix_socket_drain(
+        _,
+        %{count: count, total: total, index: index, rounds: rounds},
+        %{log: level} = meta,
+        _
+      ) do
     Logger.log(level, fn ->
       %{socket: socket, interval: interval} = meta
 
diff --git a/lib/phoenix/socket/message.ex b/lib/phoenix/socket/message.ex
index 584d63e9d..b35f15ba7 100644
--- a/lib/phoenix/socket/message.ex
+++ b/lib/phoenix/socket/message.ex
@@ -35,6 +35,19 @@ defmodule Phoenix.Socket.Message do
         raise Phoenix.Socket.InvalidMessageError, "missing key #{inspect(err.key)}"
     end
   end
+
+  defimpl Inspect do
+    def inspect(%Phoenix.Socket.Message{} = msg, opts) do
+      processed_msg = process_message(msg)
+      Inspect.Any.inspect(processed_msg, opts)
+    end
+
+    defp process_message(%{payload: payload} = msg) when is_map(payload) do
+      %{msg | payload: Phoenix.Logger.filter_values(payload)}
+    end
+
+    defp process_message(msg), do: msg
+  end
 end
 
 defmodule Phoenix.Socket.Reply do


Test patch:
diff --git a/guides/testing/testing_contexts.md b/guides/testing/testing_contexts.md
index 71345b93e..7488f5c66 100644
--- a/guides/testing/testing_contexts.md
+++ b/guides/testing/testing_contexts.md
@@ -126,7 +126,7 @@ use Hello.DataCase, async: true
 
 There are some considerations you need to have in mind when running asynchronous tests with the sandbox, so please refer to the [`Ecto.Adapters.SQL.Sandbox`](https://hexdocs.pm/ecto_sql/Ecto.Adapters.SQL.Sandbox.html) for more information.
 
-Finally at the end of the of the `DataCase` module we can find a function named `errors_on` with some examples of how to use it. This function is used for testing any validation we may want to add to our schemas. Let's give it a try by adding our own validations and then testing them.
+Finally, at the end of the `DataCase` module we can find a function named `errors_on` with some examples of how to use it. This function is used for testing any validation we may want to add to our schemas. Let's give it a try by adding our own validations and then testing them.
 
 ## Testing schemas
 
diff --git a/guides/testing/testing_controllers.md b/guides/testing/testing_controllers.md
index 802e35c2d..3da1c33c7 100644
--- a/guides/testing/testing_controllers.md
+++ b/guides/testing/testing_controllers.md
@@ -105,7 +105,7 @@ end
 
 The first test starts with a `post/2` request. That's because once the form in the `/posts/new` page is submitted, it becomes a POST request to the create action. Because we have supplied valid attributes, the post should have been successfully created and we should have redirected to the show action of the new post. This new page will have an address like `/posts/ID`, where ID is the identifier of the post in the database.
 
-We then use `redirected_params(conn)` to get the ID of the post and then match that we indeed redirected to the show action. Finally, we do request a `get` request to the page we redirected to, allowing us to verify that the post was indeed created.
+We then use `redirected_params(conn)` to get the ID of the post and then match that we indeed redirected to the show action. Finally, we do a `get` request to the page we redirected to, allowing us to verify that the post was indeed created.
 
 For the second test, we simply test the failure scenario. If any invalid attribute is given, it should re-render the "New Post" page.
 
diff --git a/test/phoenix/logger_test.exs b/test/phoenix/logger_test.exs
index 992876140..b8db41aad 100644
--- a/test/phoenix/logger_test.exs
+++ b/test/phoenix/logger_test.exs
@@ -8,6 +8,9 @@ defmodule Phoenix.LoggerTest do
 
       assert Phoenix.Logger.filter_values(values, ["password"]) ==
                %{"foo" => "bar", "password" => "[FILTERED]"}
+
+      assert Phoenix.Logger.filter_values(values, Phoenix.Logger.compile_filter(["password"])) ==
+               %{"foo" => "bar", "password" => "[FILTERED]"}
     end
 
     test "when a map has secret key" do
@@ -50,6 +53,9 @@ defmodule Phoenix.LoggerTest do
 
       assert Phoenix.Logger.filter_values(values, {:keep, []}) ==
                %{"foo" => "[FILTERED]", "password" => "[FILTERED]", "file" => "[FILTERED]"}
+
+      assert Phoenix.Logger.filter_values(values, Phoenix.Logger.compile_filter({:keep, []})) ==
+               %{"foo" => "[FILTERED]", "password" => "[FILTERED]", "file" => "[FILTERED]"}
     end
 
     test "keeps values that are specified in params" do
diff --git a/test/phoenix/socket/message_test.exs b/test/phoenix/socket/message_test.exs
new file mode 100644
index 000000000..039b5843f
--- /dev/null
+++ b/test/phoenix/socket/message_test.exs
@@ -0,0 +1,56 @@
+defmodule Phoenix.Socket.MessageTest do
+  use ExUnit.Case, async: true
+  doctest Phoenix.Socket.Message
+
+  alias Phoenix.Socket.Message
+
+  describe "inspect/2 custom implementation" do
+    test "filters sensitive values in form submit events" do
+      message = %Message{
+        topic: "lv:1",
+        event: "event",
+        payload: %{
+          "event" => "submit",
+          "type" => "form",
+          "value" => "username=john&password=secret123&email=john@example.com"
+        },
+        ref: "1",
+        join_ref: "1"
+      }
+
+      assert inspect(message) =~ "\"value\" => \"[FILTERED]\""
+    end
+
+    test "filters sensitive values at the end of form submit events" do
+      message = %Message{
+        topic: "lv:1",
+        event: "event",
+        payload: %{
+          "event" => "submit",
+          "type" => "form",
+          "value" => "username=john&password=secret123"
+        },
+        ref: "1",
+        join_ref: "1"
+      }
+
+      assert inspect(message) =~ "\"value\" => \"[FILTERED]\""
+    end
+
+    test "handles malformed query strings gracefully" do
+      message = %Message{
+        topic: "lv:1",
+        event: "event",
+        payload: %{
+          "event" => "submit",
+          "type" => "form",
+          "value" => "invalid=query=string&password=secret"
+        },
+        ref: "1",
+        join_ref: "1"
+      }
+
+      assert inspect(message) =~ "\"value\" => \"[FILTERED]\""
+    end
+  end
+end