igmarin/elixir-phoenix-skills

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 …

First seen Jun 22, 2026

Installation

$ npx skills add igmarin/elixir-phoenix-skills --skill apply-phoenix-controller-conventions

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from igmarin/elixir-phoenix-skills · top by installs.

npx skills add igmarin/elixir-phoenix-skills

Browse all from igmarin/elixir-phoenix-skills

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 2
License LICENSE
Default branch main
Open issues 0
Status Active

Skill metadata

Parsed from SKILL.md frontmatter.

Version1.0.0
LicenseMIT
More metadata
version
1.0.0
user-invocable
true

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 9,695 B
  • docs SUMMARY.md 567 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 18 installs

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.

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

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](#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 :fetchsession and :protectfrom_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: userprovidedurl) 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