Redirecting routes in a Phoenix application using plugs

Wil Hall

There are many reasons we might want to redirect routes in our phoenix application such as the name of a domain concept changing or the removal of a page from the application.

As an example, let’s say we have the following route /home and we want to change it to /welcome:

scope "/", AppWeb do
  pipe_through :browser

  get "/home", HomeController, :index
end

One of the most common solutions — and perhaps the most straightforward — is to create a WelcomeController, rename the view module and template file, and redirect from the old controller:

defmodule AppWeb.HomeController do
  use AppWeb, :controller

  def index(conn, _params) do
    welcome_path = Routes.welcome_path(conn, :index)

    conn |> redirect(to: welcome_path) |> halt()
  end
end

And then we can reference both controllers from our router.ex:

scope "/", AppWeb do
  pipe_through :browser

  get "/home", HomeController, :index
  get "/welcome", WelcomeController, :index
end

Functionally, there is nothing wrong with this approach. It gets the job done, and it’s simple to implement. But there are a some significant downsides:

  1. We have to keep both the old controller and the new controller around
  2. If our redirection behavior were even slightly more complex — such as handling a URL parameter — it would feel like the old controller knew too much about the new controller’s implementation
  3. Someone looking at router.ex can’t know whether get "/home", HomeController, :index is rendering a page that exists, or performing a redirection
  4. We can’t test the redirection behavior without writing a controller test

The solution to these problems is to extract the redirection logic and responsibility away from the controller.

Where should that logic live? In a Plug, of course!

Redirecting a single route

The Phoenix.Router.forward/4 macro allows us to forward requests made for a given path to a named plug; for example:

scope "/", AppWeb do
  pipe_through :browser

  get "/welcome", WelcomeController, :index
  forward "/home", Plugs.WelcomePageRedirector
end

Even without knowing the implementation details of Plugs.WelcomePageRedirector, it is still clear from a quick look looking at the route definition that the /home route is performing a redirect.

If we then define Plugs.WelcomePageRedirector in app/lib/app_web/plugs/welcome_page_redirector.ex, a simple implementation might look like this:

defmodule AppWeb.Plugs.WelcomePageRedirector do
  alias AppWeb.Router.Helpers, as: Routes

  def init(default), do: default

  def call(conn, _opts) do
    welcome_path = Routes.welcome_path(conn, :index)

    conn
    |> Phoenix.Controller.redirect(to: welcome_path)
    |> Plug.Conn.halt()
  end
end

The implementation of the plug is almost identical to how we previously implemented the redirect in the controller. But encapsulating the logic into a plug means that we can easily test the plug in isolation from the controller.

Redirecting multiple routes

If you have multiple routes you want to redirect, a single plug for each route is a viable option. And there is value in keeping the plug implementations straightforward to make them easier to understand and test.

But in situations where you have a set of routes you want to redirect in a predictable way, you can write a more dynamic plug.

As an example, let’s say we have a ProfileController that handles all requests to the route /profile/:id/* and we want to rename it to UserController and move its routes to /user/:id/*.

The forward/4 macro forwards all requests starting with the given path, so the following would handle any route starting with /profile:

scope "/", AppWeb do
  pipe_through :browser

  resources "/user", UserController 
  forward "/profile", Plugs.ProfileRedirector
end

And Plugs.ProfileRedirector has access to the request fields from Plug.Conn, allowing us to dynamically redirect the request in our plug implementation based on the user id path parameter:

defmodule AppWeb.Plugs.ProfileRedirector do
  alias AppWeb.Router.Helpers, as: Routes

  def init(default), do: default

  def call(conn, _opts) do
    [id, _tail] = conn.path_info

    user_path = Routes.user_path(conn, :show, id)

    conn
    |> Phoenix.Controller.redirect(to: user_path)
    |> Plug.Conn.halt()
  end
end

The above implementation does not attempt to make all resource routes work. Instead, it simply redirects them to the :show page under the new /user URL path.

This is a trade-off. We could write a complex plug that allows all requests to the old route /profile/:id/* to function as if they were made to /user/:id/*, but we can avoid that complexity if our use case is just to redirect lost users to the new route.

Ideally we want to keep these plugs short and simple, ensuring that they are easy to understand and easy to test.