5 min read
On this page

Controllers and Views

A Phoenix controller's job is narrow: parse the request, call into your domain, render a response. The mistake new Phoenix developers make is treating controllers like Rails controllers — stuffing business logic, formatting, validation, and orchestration into one place. Phoenix's design encourages something tighter. The controller is supposed to be boring. The interesting code lives in contexts.

Phoenix 1.7 also reshaped the view layer. The old "view modules" are gone, replaced by a component-based system using HEEx templates and function components that look more like modern frontend frameworks than the old Rails-style ERB templates.

Controller Basics

A controller is a module with action functions, each receiving a conn and params:

defmodule HelloWeb.ArticleController do
  use HelloWeb, :controller

  alias Hello.Content

  def index(conn, _params) do
    articles = Content.list_articles()
    render(conn, :index, articles: articles)
  end

  def show(conn, %{"id" => id}) do
    article = Content.get_article!(id)
    render(conn, :show, article: article)
  end

  def create(conn, %{"article" => article_params}) do
    case Content.create_article(article_params) do
      {:ok, article} ->
        conn
        |> put_flash(:info, "Article created successfully.")
        |> redirect(to: ~p"/articles/#{article}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :new, changeset: changeset)
    end
  end
end

A few things to notice. The action calls Content.list_articles/0 — that's the context, not Ecto directly. The controller doesn't know about the database. It also doesn't know about formatting — render/3 hands off to the template layer.

use HelloWeb, :controller imports the standard helpers. ~p"/articles/#{article}" is a verified route — at compile time, Phoenix checks that this route actually exists in your router. Typo a path and you get a compile error, not a 404 in production.

The conn Pipeline Style

Controller actions can use conn as a building block, piping it through transformations:

def show(conn, %{"id" => id}) do
  article = Content.get_article!(id)

  conn
  |> assign(:page_title, article.title)
  |> put_resp_header("cache-control", "public, max-age=300")
  |> render(:show, article: article)
end

Every helper is a function from conn to conn, just like plugs. Once you internalize this, the difference between a plug and a controller action is mostly that the action is the last step.

Rendering JSON

For APIs, you skip templates and render JSON directly:

def index(conn, _params) do
  users = Accounts.list_users()
  json(conn, %{data: users})
end

def create(conn, %{"user" => params}) do
  case Accounts.create_user(params) do
    {:ok, user} ->
      conn
      |> put_status(:created)
      |> json(%{data: user})

    {:error, changeset} ->
      conn
      |> put_status(:unprocessable_entity)
      |> json(%{errors: errors_from(changeset)})
  end
end

For larger JSON APIs, define a JSON module per controller to keep the response shape separate:

defmodule HelloWeb.UserJSON do
  def index(%{users: users}), do: %{data: for(u <- users, do: data(u))}
  def show(%{user: user}), do: %{data: data(user)}

  defp data(user) do
    %{
      id: user.id,
      name: user.name,
      email: user.email,
      inserted_at: user.inserted_at
    }
  end
end

Then in the controller:

def index(conn, _params) do
  users = Accounts.list_users()
  render(conn, :index, users: users)
end

The convention HelloWeb.UserController looks for HelloWeb.UserJSON (for JSON requests) or HelloWeb.UserHTML (for HTML). The function name matches the action — :index calls index/1 in the JSON module.

HEEx Templates

HEEx is Phoenix's template language. It's HTML with embedded Elixir, but stricter than the old EEx — it parses the HTML structure and refuses to compile invalid markup, prevents accidental XSS by escaping by default, and provides component composition.

<.header>Articles</.header>

<.table id="articles" rows={@articles}>
  <:col :let={article} label="Title">
    <.link navigate={~p"/articles/#{article}"}><%= article.title %></.link>
  </:col>
  <:col :let={article} label="Published">
    <%= article.published_at %>
  </:col>
</.table>

<.link navigate={~p"/articles/new"}>New Article</.link>

Notable bits:

  • <%= ... %> interpolates Elixir expressions, escaping HTML by default.
  • @articles is an assign passed from the controller.
  • <.header>, <.table>, <.link> are function components.
  • <:col> is a slot — components can have multiple named slots.
  • :let={article} exposes data from the slot to the caller.

If you've used JSX, the syntax for components and slots will feel familiar. The big difference is that HEEx renders on the server.

Function Components

Components are functions that return rendered HTML. They live in a module marked with use Phoenix.Component:

defmodule HelloWeb.CoreComponents do
  use Phoenix.Component

  attr :class, :string, default: nil
  slot :inner_block, required: true

  def card(assigns) do
    ~H"""
    <div class={["rounded border p-4", @class]}>
      <%= render_slot(@inner_block) %>
    </div>
    """
  end

  attr :type, :string, default: "button"
  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def button(assigns) do
    ~H"""
    <button
      type={@type}
      class={["btn", @class]}
      {@rest}
    >
      <%= render_slot(@inner_block) %>
    </button>
    """
  end
end

attr declares typed attributes. slot declares slot points. attr :rest, :global collects any other HTML attributes (like phx-click, data-*, aria-*). ~H"""...""" is the HEEx sigil — same syntax as templates, just inline.

Phoenix 1.7 generates a core_components.ex with form helpers, modals, flash messages, tables, and inputs already implemented. You'll customize them, but the starting point is solid.

Layouts

Layouts wrap content in shared structure (HTML head, navigation, footer). Phoenix uses two by default:

  • Root layout — the full HTML document. Set in the router pipeline with plug :put_root_layout.
  • App layout — wraps the page content inside the body. Used for things that change between pages (flash messages, navigation visibility).
<!-- root.html.heex -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title><%= assigns[:page_title] || "Hello" %></title>
    <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
  </head>
  <body>
    <%= @inner_content %>
  </body>
</html>
<!-- app.html.heex -->
<header>
  <nav>...</nav>
</header>

<main>
  <.flash_group flash={@flash} />
  <%= @inner_content %>
</main>

@inner_content is where the rendered template gets injected.

You can disable layouts for a specific action:

def widget(conn, _params) do
  conn
  |> put_root_layout(false)
  |> put_layout(false)
  |> render(:widget)
end

Or set a different layout per controller:

plug :put_root_layout, html: {HelloWeb.Layouts, :admin} when action in [:dashboard]

conn Manipulation Helpers

A handful of functions you'll use constantly:

conn
|> put_status(:not_found)              # set HTTP status
|> put_resp_header("x-custom", "1")    # set a response header
|> put_flash(:info, "Saved")           # flash message for next request
|> put_session(:user_id, user.id)      # set a session value
|> get_session(:user_id)               # read a session value
|> assign(:current_user, user)         # request-scoped assign
|> redirect(to: ~p"/dashboard")        # redirect to a route
|> redirect(external: "https://...")   # redirect to external URL
|> halt()                              # stop further plugs (in plugs)

Status can be an atom (:ok, :not_found, :unprocessable_entity) or an integer (200, 404, 422). Atoms are clearer; integers are sometimes necessary for unusual codes.

Action Fallback

When several actions share the same error handling, factor it into an action fallback:

defmodule HelloWeb.UserController do
  use HelloWeb, :controller
  alias Hello.Accounts

  action_fallback HelloWeb.FallbackController

  def show(conn, %{"id" => id}) do
    with {:ok, user} <- Accounts.fetch_user(id) do
      render(conn, :show, user: user)
    end
  end

  def update(conn, %{"id" => id, "user" => params}) do
    with {:ok, user} <- Accounts.fetch_user(id),
         {:ok, updated} <- Accounts.update_user(user, params) do
      render(conn, :show, user: updated)
    end
  end
end

defmodule HelloWeb.FallbackController do
  use HelloWeb, :controller

  def call(conn, {:error, :not_found}) do
    conn |> put_status(:not_found) |> json(%{error: "not found"})
  end

  def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
    conn |> put_status(:unprocessable_entity) |> json(%{errors: errors_from(changeset)})
  end
end

The fallback receives any non-conn return value from an action. Combined with with, this keeps controllers tight — no error case clutter, just the happy path.

Forms with to_form

Forms need a Phoenix.HTML.Form struct. Convert a changeset to one with to_form/1:

def new(conn, _params) do
  changeset = Content.change_article(%Content.Article{})
  render(conn, :new, form: to_form(changeset))
end
<.form for={@form} action={~p"/articles"}>
  <.input field={@form[:title]} label="Title" />
  <.input field={@form[:body]} type="textarea" label="Body" />
  <.button type="submit">Save</.button>
</.form>

<.input> is a function component (in core_components.ex) that renders a labeled, error-aware input. @form[:title] accesses the field — name, value, errors — and the input renders accordingly.

Common Pitfalls

Putting business logic in actions. A controller action should fit on a screen. If it doesn't, the logic belongs in a context. Look at the imports — if your controller imports Ecto.Query, you're doing it wrong.

Forgetting put_status before json/2. json/2 doesn't change the status. If you don't call put_status, you'll send a 200 with an error body. Always set the status explicitly when it isn't 200.

Returning conn versus a tuple from actions. Actions should either return a conn (after rendering or redirecting) or use the action_fallback pattern with {:ok, _} / {:error, _} returns. Mixing the two in the same controller is confusing.

Using assigns for cross-request state. conn.assigns is reset on every request. Putting "the current user" there is fine. Putting "the user's preferences cache" expecting it to persist isn't.

Bypassing components. New Phoenix code should use components, not raw HTML in templates. Once you have a <.button>, <.card>, and <.input> set up correctly with classes and accessibility, using them everywhere keeps the design consistent.

Forgetting halt() in custom plugs. A plug that renders an early response and then doesn't halt will cause the rest of the pipeline (including the controller action) to run, often producing a Plug.Conn.AlreadySentError.

Key Takeaways

  • Controllers are thin — parse params, call contexts, render. Business logic doesn't belong here.
  • render/3 looks for a template module by convention (UserControllerUserHTML or UserJSON).
  • HEEx templates are HTML with stricter parsing, automatic escaping, and components.
  • Function components live in modules with use Phoenix.Component. They take attr and slot declarations and return HEEx.
  • conn is the universal data structure — every helper transforms it. Pipelines of conn operations are the controller idiom.
  • Action fallback + with keeps action functions tight and centralizes error handling.