Apply Phoenix Controller Conventions
Use this skill when writing new Phoenix controller modules or modifying existing controller code to ensure consistent, idiomatic patterns.
Precondition: Invoke phoenix-liveview-essentials before this skill if the feature uses LiveView; for traditional request/response, use this skill directly.
Canonical FP bar: docs/fcis-engineering-rules.md — Functional Core, Imperative Shell: pure domain modules; side effects at edges. Keep LiveView/controller callbacks thin; delegate business rules to contexts/pure modules.
Quick Reference
| Pattern | Convention |
|---|---|
| Routes | resources for RESTful; scope for grouping |
| Controllers | Thin — delegate business logic to contexts |
| Controller plugs | For auth, resource loading; halt or return conn |
| Strong params | Use changeset validation or cast/4 in context |
| Content type | Pipeline :browser for HTML; :api for JSON |
| Error handling | Use FallbackController for structured errors |
| Auth plugs | Include pipeline plugs; skip with :skip option |
RULES — Follow these with no exceptions
1. Keep controllers thin — never put business logic in controllers; delegate to context modules
2. Use plug guards for authentication and resource loading — chain with when action not in [...] opt-out pattern
3. Always validate and authorize every action that touches access-controlled resources
4. Use FallbackController for JSON API error handling — never inline catch-all case clauses in actions
5. Match content pipeline to format — API pipeline (no session, no CSRF) for JSON; browser pipeline for HTML
6. Use conn.assigns for passing data between plugs and actions — never use Process dictionaries
7. Never interpolate user input into redirect paths — use ~p"..." paths for verified routes
FCIS at this boundary
Controllers are edges: cast params, call context, render. No business math or multi-step domain orchestration in the action body.
❌ Bad: fat controller
def create(conn, %{"post" => params}) do
params = Map.put(params, "slug", slugify(params["title"]))
{:ok, post} = Repo.insert(Post.changeset(%Post{}, params))
redirect(conn, to: ~p"/posts/#{post}")
end
✅ Good: thin action
def create(conn, %{"post" => params}) do
case Blog.create_post(conn.assigns.current_scope, params) do
{:ok, post} -> redirect(conn, to: ~p"/posts/#{post}")
{:error, changeset} -> render(conn, :new, form: to_form(changeset))
end
end
Routing Conventions
✅ RESTful resources with explicit shallow-style split (Phoenix has no shallow: true):
scope "/", MyAppWeb do
pipe_through :browser
# Collection under parent
resources "/users", UserController do
resources "/posts", PostController, only: [:index, :new, :create]
end
# Member routes at top level
resources "/posts", PostController, only: [:show, :edit, :update, :delete]
end
Checkpoint: Run mix phx.routes to verify routes resolve correctly and there are no unintended deep-nesting paths.
Plug Pipeline Ordering
✅ plug guards with opt-out, auth at controller level:
defmodule MyAppWeb.UserController do
use MyAppWeb, :controller
plug :require_authenticated_user when action not in [:index, :show]
plug :load_user when action in [:edit, :update]
def index(conn, _params) do
users = Accounts.list_users()
render(conn, :index, users: users)
end
def edit(conn, _params) do
render(conn, :edit, user: conn.assigns.user)
end
def update(conn, %{"user" => user_params}) do
case Accounts.update_user(conn.assigns.user, user_params) do
{:ok, user} -> redirect(conn, to: ~p"/users/#{user}")
{:error, changeset} -> render(conn, :edit, user: conn.assigns.user, changeset: changeset)
end
end
defp require_authenticated_user(conn, _opts) do
if conn.assigns[:current_user] do
conn
else
conn
|> put_flash(:error, "You must be logged in")
|> redirect(to: ~p"/login")
|> halt()
end
end
defp load_user(conn, _opts) do
user = Accounts.get_user!(conn.params["id"])
assign(conn, :user, user)
end
end
Checkpoint: Confirm plug ordering with mix phx.routes and verify that exempt actions (e.g., :index, :show) do not trigger auth plugs in integration tests.
Action Patterns
✅ Thin controller delegating to context:
def create(conn, %{"user" => user_params}) do
case Accounts.register_user(user_params) do
{:ok, user} ->
conn
|> put_flash(:info, "User created")
|> redirect(to: ~p"/users/#{user}")
{:error, changeset} ->
render(conn, :new, changeset: changeset)
end
end
For JSON API endpoints, use with + FallbackController instead of case:
def create(conn, %{"user" => user_params}) do
with {:ok, user} <- Accounts.register_user(user_params) do
conn
|> put_status(:created)
|> render(:show, user: user)
end
end
Strong Parameters / Params Validation
Validation belongs in the context, not the controller. The controller passes params through unchanged (see the update/2 example in Plug Pipeline Ordering), while the context enforces permitted fields:
# Context — enforce permitted fields via changeset
def update_user(user, attrs) do
user
|> User.changeset(attrs) # cast/2 only permits declared fields
|> Repo.update()
end
Content Negotiation
✅ API pipeline for JSON, browser pipeline for HTML:
# Router
scope "/api", MyAppWeb do
pipe_through :api
resources "/users", Api.UserController, only: [:index, :show]
end
# Phoenix API pipeline (router.ex)
pipeline :api do
plug :accepts, ["json"]
end
# Controller — use render/3, not json/2, so views handle serialisation
def index(conn, _params) do
users = Accounts.list_users()
render(conn, :index, users: users)
end
Checkpoint: Confirm the correct pipeline is applied by inspecting mix phx.routes output and checking that API routes lack :fetch_session and :protect_from_forgery plugs.
FallbackController for JSON APIs
✅ action_fallback + centralised FallbackController:
defmodule MyAppWeb.UserController do
use MyAppWeb, :controller
action_fallback MyAppWeb.FallbackController
def show(conn, %{"id" => id}) do
with {:ok, user} <- Accounts.get_user(id) do
render(conn, :show, user: user)
end
end
end
defmodule MyAppWeb.FallbackController do
use MyAppWeb, :controller
def call(conn, {:error, :not_found}) do
conn
|> put_status(:not_found)
|> json(%{error: "Not found"})
end
def call(conn, {:error, :unauthorized}) do
conn
|> put_status(:forbidden)
|> json(%{error: "Forbidden"})
end
end
Checkpoint: Run mix test test/controllers/ after wiring up FallbackController to confirm each expected error tuple ({:error, :not_found}, {:error, :unauthorized}) is matched and returns the correct HTTP status.
Error Handling — Browser
✅ Pattern match on expected errors; redirect with flash:
def show(conn, %{"id" => id}) do
case Accounts.get_user(id) do
{:ok, user} ->
render(conn, :show, user: user)
{:error, :not_found} ->
conn
|> put_flash(:error, "User not found")
|> redirect(to: ~p"/users")
|> halt()
end
end
Avoid get_user!/1 (raises) for user-triggered lookups; reserve bang variants for developer errors where a crash is the correct signal.
Checkpoint: Verify error paths in browser tests by asserting flash messages and redirect targets.
Common Pitfalls
| ❌ Wrong | ✅ Correct |
|---|---|
Business logic in controller (Repo.insert inline) |
Delegate to context module (Accounts.create_user) |
| Auth plug without action guard on public actions | Use plug :auth when action not in [:index, :show] |
redirect(to: user_provided_url) |
Use ~p"..." verified path helpers |
| JSON error handling duplicated in each action | Use action_fallback FallbackController |
pipe_through :browser for JSON endpoints |
Use pipe_through :api for JSON scopes |
Process dictionary for inter-plug data |
Use conn.assigns |
Integration
| Predecessor | This Skill | Successor |
|---|---|---|
| elixir-essentials | apply-phoenix-controller-conventions | code-quality |
| phoenix-json-api | apply-phoenix-controller-conventions | testing-essentials |
Companion skills:
phoenix-json-api— RESTful API controller patterns and versioningphoenix-liveview-essentials— LiveView for interactive pagesphoenix-scopes— authentication and authorization setupphoenix-uploads— file upload in controller actions