DEV Community

Cover image for Diving into the macros that make Phoenix controllers work
Adam Davis
Adam Davis

Posted on • Originally published at brewinstallbuzzwords.com

Diving into the macros that make Phoenix controllers work

You’ve made a Phoenix controller before, but do you know how it actually works? Let’s explore some code together.

Make a new project

First, we’re going to make a new phoenix project. I’m including the --no-ecto flag because I don’t plan on using any database or changeset functionality in this post.

mix phx.new controller_dissection --no-ecto
Enter fullscreen mode Exit fullscreen mode

All the code you need to see will be included in the text of this post. But feel free to follow along by making the project on your machine or by following along with this GitHub repo.

The new project should look something like this (not every file is shown in this tree):

.
├── README.md
├── _build
├── assets
├── config
├── deps
├── lib
│   ├── controller_dissection
│   │   ├── application.ex
│   │   └── mailer.ex
│   ├── controller_dissection.ex
│   ├── controller_dissection_web
│   │   ├── controllers
│   │   │   └── page_controller.ex
│   │   ├── endpoint.ex
│   │   ├── gettext.ex
│   │   ├── router.ex
│   │   ├── telemetry.ex
│   │   ├── templates
│   │   │   ├── layout
│   │   │   │   ├── app.html.heex
│   │   │   │   ├── live.html.heex
│   │   │   │   └── root.html.heex
│   │   │   └── page
│   │   │       └── index.html.heex
│   │   └── views
│   │       ├── error_helpers.ex
│   │       ├── error_view.ex
│   │       ├── layout_view.ex
│   │       └── page_view.ex
│   └── controller_dissection_web.ex
├── mix.exs
├── mix.lock
├── priv
└── test

Enter fullscreen mode Exit fullscreen mode

We haven’t done anything special yet, this is just the default structure for a Phoenix project.

Down the rabbit hole

In this post we’re interested in the controllers. To find them, we need to look in lib/controller_dissectino_web/controllers

You’ll only have one controller by default, and it’s in page_controller.ex. Here’s what it should look like:

defmodule ControllerDissectionWeb.PageController do
  use ControllerDissectionWeb, :controller

  def index(conn, _params) do
    render(conn, "index.html")
  end
end
Enter fullscreen mode Exit fullscreen mode

This line near the top should look pretty familiar if you’ve made a Phoenix controller before:

use ControllerDissectionWeb, :controller
Enter fullscreen mode Exit fullscreen mode

But what does it actually do?

use/2 is a macro that calls the __using__ macro for the given module. Here, the module is ControllerDissectionWeb.

If we open up lib/controller_dissection_web.ex then we’ll see the following __using__ macro defined near the bottom of the file:

@doc """
When used, dispatch to the appropriate controller/view/etc.
"""
defmacro __using__(which) when is_atom(which) do
  apply(__MODULE__, which, [])
end
Enter fullscreen mode Exit fullscreen mode

The __MODULE__ special form provides the atom for the current module, and the which parameter would be :controller, because that’s what’s being passed in with the use call in page_controller.ex.

So all that happens with this use statement is that ControllerDissectionWeb.controller/0 is called.

If we take a look at ControllerDissectionWeb.controller/0 we’ll see the real meat of the Phoenix controller:

def controller do
  quote do
    use Phoenix.Controller, namespace: ControllerDissectionWeb

    import Plug.Conn
    import ControllerDissectionWeb.Gettext
    alias ControllerDissectionWeb.Router.Helpers, as: Routes
  end
end
Enter fullscreen mode Exit fullscreen mode

This function is a macro that will inject code into modules such that they will be able to carry out the defined behavior of Phoenix controllers.

While I won’t get too into the weeds in this post, I encourage you to follow the definition of Phoenix.Controller to see the Phoenix functions that get imported into your controllers.

Injecting our own code into controllers

To test that this code is actually injected into our controllers, we can import a module here and see if we can access it within a controller. It’s generally better to put new modules in a new file, but for simplicity let’s just put it in lib/controller_dissection_web.ex.

Within this new module, we’ll make a simple hello world function:

defmodule HelloWorld do
  def hello_world do
    IO.puts("hello, world!")
  end
end

defmodule ControllerDissectionWeb do
  # ...
end
Enter fullscreen mode Exit fullscreen mode

Now, let’s add an import to the HelloWorld module inside the controller/0 macro:

def controller do
  quote do
    use Phoenix.Controller, namespace: ControllerDissectionWeb

    import Plug.Conn
    import ControllerDissectionWeb.Gettext
    alias ControllerDissectionWeb.Router.Helpers, as: Routes

    import HelloWorld
  end
end
Enter fullscreen mode Exit fullscreen mode

To test out that it’s available, navigate back to lib/controller_dissection_web/controllers/page_controller.ex and try calling hello_world/0 within the index/2 function. If successful, the string "hello, world!" will be printed whenever the index page is loaded.

defmodule ControllerDissectionWeb.PageController do
  use ControllerDissectionWeb, :controller

  def index(conn, _params) do
    hello_world()
    render(conn, "index.html")
  end
end
Enter fullscreen mode Exit fullscreen mode

Now that everything is in place, we’ll run the server with mix phx.server and open up localhost:4000

If all goes well, you should see this in the console:

[info] GET /
hello, world!
Enter fullscreen mode Exit fullscreen mode

What else can we do?

So now that you know how this works, what can you do with this knowledge?

Here’s a few things that come to mind:

  • common utils library
  • action handlers that you want to be present on every controller
  • make a more specific type of controller that calls controller/0 but also has additional functionality

Just for fun, let’s see if we can do a trivial implementation of that last option.

Extending controller functionality

Luckily, for making our own custom type of controller, there’s some existing code in controller_dissection_web.ex that we can use as a reference.

The view_helpers/0 function defines some common functionality that is used in different types of views:

def view do
  quote do
    use Phoenix.View,
      root: "lib/controller_dissection_web/templates",
      namespace: ControllerDissectionWeb

    # Import convenience functions from controllers
    import Phoenix.Controller,
      only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1]

    # Include shared imports and aliases for views
    unquote(view_helpers())
  end
end

def live_view do
  quote do
    use Phoenix.LiveView,
      layout: {ControllerDissectionWeb.LayoutView, "live.html"}

    unquote(view_helpers())
  end
end

# ...

defp view_helpers do
  quote do
    # Use all HTML functionality (forms, tags, etc)
    use Phoenix.HTML

    # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc)
    import Phoenix.LiveView.Helpers

    # Import basic rendering functionality (render, render_layout, etc)
    import Phoenix.View

    import ControllerDissectionWeb.ErrorHelpers
    import ControllerDissectionWeb.Gettext
    alias ControllerDissectionWeb.Router.Helpers, as: Routes
  end
end
Enter fullscreen mode Exit fullscreen mode

The view/0 and live_view/0 functions both provide macros that nest the view_helpers/0 macro within them, allowing for multiple types of views that have some shared functionality. If you take a look around that file, you can find a few other functions that also call view_helpers/0.

All that’s needed to make this work is to pass the nested macro into unquote/1 and follow it up with whatever else you want to include in the macro.

So we can treat controller/0 as the shared code for all controllers, and then make another macro function with a call to controller/0.

First, we need to make a new module with the functionality for the new controller type.

defmodule CustomControllerUtils do
  def flash_hello_world(conn, _opts) do
    conn
    |> Phoenix.Controller.put_flash(:info, "hello, world!")
  end
end
Enter fullscreen mode Exit fullscreen mode

Then, we can make the macro function for our custom controller type. All that’s special about our custom controller is that we’ll have a plug that puts a flash message of “hello, world!” This particular functionality is arbitrary and is only here to demonstrate that you could insert any code you like.

def custom_controller do
  quote do
    unquote(controller())

    import CustomControllerUtils

    plug :flash_hello_world
  end
end
Enter fullscreen mode Exit fullscreen mode

Screenshot of hello world flash message

How would you use this?

Have you ever made changes to the Phoenix controller macros? Did this post help you think of a good use case? I’d love to hear about it. Let me know in the comments!

Latest comments (1)

Collapse
 
brewinstallbuzzwords profile image
Adam Davis

Have you ever made changes to the Phoenix macros?

Have you ever found a situation where macros were helpful in a Phoenix or Elixir project?