agentleFS
igmarinigmarin/elixir-phoenix-skills/skills/apply-phoenix-controller-conventions/SKILL.md

apply-phoenix-controller-conventions

Skill4 starsChanged 4 months agoMITView on GitHub

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.

The file

We split this skill into the rules it gives an agent

5 rules. About 0 are common, 2 are distinctive, and 3 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈2

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 3

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Use this skill when writing new Phoenix controller modules or modifying existing controller code to ensure consistent, idiomatic patterns.

    line 22
  2. Very specific:

    Checkpoint: Run mix phx.routes to verify routes resolve correctly and there are no unintended deep-nesting paths.

    line 93
  3. Very specific:

    Checkpoint: Run mix test test/controllers/ after wiring up FallbackController to confirm each expected error tuple ({:error, :not_found}, …

    line 244
  4. Very specific:

    Avoid get_user!/1 (raises) for user-triggered lookups.

    line 265
  5. Distinctive:

    Checkpoint: Verify error paths in browser tests by asserting flash messages and redirect targets.

    line 267

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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.

Quick Reference

PatternConvention
Routesresources for RESTful; scope for grouping
ControllersThin — delegate business logic to contexts
Controller plugsFor auth, resource loading; halt or return conn
Strong paramsUse changeset validation or cast/4 in context
Content typePipeline :browser for HTML; :api for JSON
Error handlingUse FallbackController for structured errors
Auth plugsInclude 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 actionsUse plug :auth when action not in [:index, :show]
redirect(to: user_provided_url)Use ~p"..." verified path helpers
JSON error handling duplicated in each actionUse action_fallback FallbackController
pipe_through :browser for JSON endpointsUse pipe_through :api for JSON scopes
Process dictionary for inter-plug dataUse conn.assigns

Integration

PredecessorThis SkillSuccessor
elixir-essentialsapply-phoenix-controller-conventionscode-quality
phoenix-json-apiapply-phoenix-controller-conventionstesting-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

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.