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.@articlesis 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/3looks for a template module by convention (UserController→UserHTMLorUserJSON).- HEEx templates are HTML with stricter parsing, automatic escaping, and components.
- Function components live in modules with
use Phoenix.Component. They takeattrandslotdeclarations and return HEEx. connis the universal data structure — every helper transforms it. Pipelines ofconnoperations are the controller idiom.- Action fallback +
withkeeps action functions tight and centralizes error handling.