# Apply Phoenix Controller Conventions

> Use when writing new controller code in Phoenix applications. Enforces consistent patterns for RESTful routing, plug pipeline ordering, action methods, strong parameters, content negotiation, fallback controllers, and error handling. Covers resource routing, controller plugs, conn.assigns, json/html rendering, and authentication plugs. Trigger words: phoenix controller conventions, controller patterns, phoenix router, plug pipeline, controller plugs, fallback controller, strong params, phoenix routes, action fallback.

- Skill: `igmarin/apply-phoenix-controller-conventions` (Agent Skill)
- Install (CLI): `npx skillmds@latest add igmarin/apply-phoenix-controller-conventions`
- Raw SKILL.md: https://api.skillmd.com/api/skills/igmarin/apply-phoenix-controller-conventions/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- License: MIT
- Author: igmarin (https://skillmd.com/u/igmarin)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/igmarin/apply-phoenix-controller-conventions

---


# 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`](../../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

```elixir
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

```elixir
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`):
```elixir
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:**
```elixir
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:**
```elixir
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`:

```elixir
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](#plug-pipeline-ordering)), while the context enforces permitted fields:

```elixir
# 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:**
```elixir
# 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`:**
```elixir
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:**
```elixir
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 versioning
- `phoenix-liveview-essentials` — LiveView for interactive pages
- `phoenix-scopes` — authentication and authorization setup
- `phoenix-uploads` — file upload in controller actions

