Imported from BadBeta/elixir-phase-skills (
phoenix/SKILL.md). Install upstream withnpx skills add BadBeta/elixir-phase-skills --skill phoenix. Copyright stays with the author.
Phoenix Framework Skill
Rules for Writing Phoenix Code (LLM)
- ALWAYS use contexts for business logic — never access Repo directly from controllers or LiveViews
- ALWAYS use
to_form()and@form[:field]for forms — never access changesets directly in templates - ALWAYS use verified routes
~p"/path"— never string-concatenate paths - ALWAYS preload associations before rendering — never trigger lazy loads in templates
- ALWAYS use action-specific changesets — never cast all fields in one changeset (mass assignment risk)
- ALWAYS guard
runtime.exsconfig withif config_env() == :prod— it runs in ALL environments - ALWAYS use raw SQL or migration-local schemas in migrations — never reference application schemas
- NEVER use
String.to_atom/1with user input — atom table exhaustion attack vector - NEVER use
raw/1on unsanitized user input — XSS vulnerability - ALWAYS use
~Hsigil and{...}interpolation — never use deprecated~E,let={f}, orPhoenix.View - ALWAYS set
same_site,http_only, andsecureon session cookies in production - ALWAYS call
delete_csrf_token/0after login to prevent session fixation - ALWAYS prefer generators (
mix phx.gen.*) over hand-writing contexts, schemas, and migrations — generators produce correct, tested, consistent code - ALWAYS use battle-tested libraries for authentication, password hashing, session management, cryptography, JWTs, OAuth, secret comparison, and access tokens — NEVER hand-roll these. Hand-rolling a security-critical primitive is an exception that requires explicit justification AND a security review; the default answer is "no." A "custom requirement" (non-standard
Authorizationscheme, custom claims shape, unusual session storage, exotic token format) is almost always a configuration parameter on a real library, NOT a reason to write your own. If you find yourself reaching for raw primitives (Jokendirectly,:cryptoplus your own plug pipeline, hand-written token format) ask: which library wraps these and exposes the customization I need? Almost always the answer exists. Canonical Phoenix picks:phx.gen.auth(sessions+cookies for browser apps), Guardian (JWT for JSON APIs —VerifyHeader'sscheme:option handles non-standardAuthorizationheaders likeToken <jwt>), Ueberauth (OAuth providers),bcrypt_elixir/argon2_elixir(password hashing — lower rounds inconfig/test.exs, never via a behaviour-and-mock),Plug.Crypto.secure_compare/2(constant-time secret compare). The cost of using a library is one dep + one config block; the cost of hand-rolling is the bug you ship and don't notice.
Decision Tables
Which Route Type?
| When you need to... | Use this | NOT this |
|---|---|---|
| REST resource with CRUD | resources "/users", UserController |
Individual route macros |
| LiveView with client state | live "/dashboard", DashboardLive |
Controller + JS |
| Static page, no state | get "/about", PageController, :about |
LiveView (overhead) |
| JSON API endpoint | resources in :api pipeline |
:browser pipeline |
| WebSocket real-time | Channel via socket "/socket" |
Polling controller |
| Delegate to sub-router | forward "/admin", AdminRouter |
Duplicating pipelines |
Which Data Access Pattern?
| When you need to... | Use this | NOT this |
|---|---|---|
| Fetch by ID (may not exist) | context.get_thing(id) → nil |
Repo.get! in controller |
| Fetch by ID (must exist) | context.get_thing!(id) in controller |
Manual nil check |
| Query with filters | Context function with Ecto.Query |
Raw SQL in controller |
| Create/update with validation | Context returning {:ok, struct} / {:error, changeset} |
Repo.insert in controller |
| Form changeset for display | context.change_thing(struct) |
Building changeset in template |
Which Component Type?
| When you need to... | Use this | NOT this |
|---|---|---|
| Reusable stateless UI | Function component with attr/slot |
LiveComponent |
| Shared layout wrapper | Function component | embed_templates alone |
| Tabular data with columns | Slot attributes (:col with :let) |
Manual table HTML |
| External template files | embed_templates "components/*" |
Inline ~H for large templates |
Architecture Overview
Request Flow
Request → Endpoint → Router → Pipeline → Controller/LiveView → View/Component → Response
- Endpoint: Supervision tree root, initial plug pipeline (static files, session, CSRF)
- Router: Matches routes, applies pipelines via scope blocks
- Pipeline: Series of plugs for route groups (
:browser,:api, etc.) - Controller/LiveView: Handles business logic via context calls
- View/Component: Renders response (function components, HEEx templates)
Each request/WebSocket runs in its own BEAM process — isolation and fault tolerance by default.
Plug Fundamentals
Two Types of Plugs
# Function plug - receives and returns conn
def my_plug(conn, opts) do
conn
end
# Module plug - init at compile time, call at runtime
defmodule MyPlug do
def init(opts), do: opts # Compile time - do heavy work here
def call(conn, opts), do: conn # Runtime - keep fast
end
Key Rules
init/1executes at compile time — perfect for config processingcall/2executes at runtime — keep it fast- Every plug must return the connection
- Use
assign/3instead of process dictionary - Always define a catch-all route to avoid function clause errors
Common Plugs
plug :accepts, ["html"] # Content negotiation
plug :fetch_session # Load session data
plug :fetch_live_flash # Flash messages for LiveView
plug :put_root_layout # Set root layout
plug :protect_from_forgery # CSRF protection
plug :put_secure_browser_headers # Security headers
Router & Pipelines
Pipeline Definition
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
Scopes and Routes
scope "/", MyAppWeb do
pipe_through :browser
get "/", PageController, :home
resources "/users", UserController
live "/dashboard", DashboardLive
end
# Nested scope - alias is cumulative
scope "/admin", MyAppWeb.Admin do
pipe_through [:browser, :require_admin]
live "/users", UserLive # Points to MyAppWeb.Admin.UserLive
end
Critical: Router scope blocks include alias — don't duplicate the namespace.
Live Sessions
Group LiveView routes sharing authentication or on_mount hooks:
scope "/", MyAppWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :authenticated, on_mount: [{MyAppWeb.UserAuth, :ensure_authenticated}] do
live "/dashboard", DashboardLive
live "/settings", SettingsLive
live "/profile", ProfileLive
end
end
Content-Type-Specific Pipelines
For XML feeds, JSON feeds, or other non-HTML responses:
pipeline :feed do
plug :accepts, ["xml"]
plug :put_resp_content_type, "application/xml"
plug MyAppWeb.Plugs.CacheControl, max_age: 900
end
scope "/", MyAppWeb do
pipe_through :feed
get "/feed", FeedController, :index
get "/:podcast/feed", FeedController, :podcast
end
Path Helpers
# Use verified routes with ~p sigil
~p"/users"
~p"/users/#{user.id}"
~p"/users/#{user}/edit"
Contexts & Domain Modeling
What Contexts Are
Contexts are modules that group related functionality (bounded contexts). They:
- Encapsulate business logic separate from web interface
- Create clear public APIs for each domain
- Enable multiple consumers (web, API, CLI, channels)
- Reduce coupling between domains
Context Design
defmodule MyApp.Accounts do
@moduledoc "The Accounts context - user management"
alias MyApp.Accounts.User
alias MyApp.Repo
def get_user(id), do: Repo.get(User, id)
def get_user!(id), do: Repo.get!(User, id)
def create_user(attrs) do
%User{}
|> User.changeset(attrs)
|> Repo.insert()
end
def update_user(%User{} = user, attrs) do
user
|> User.changeset(attrs)
|> Repo.update()
end
def change_user(%User{} = user, attrs \\ %{}) do
User.changeset(user, attrs)
end
end
Context Guidelines
- Never access Repo directly from controllers/LiveViews
- Create focused contexts (Accounts, Catalog, Orders) not "one resource contexts"
- Context functions should return
{:ok, struct}or{:error, changeset} - Bang functions (
get_user!) raise on failure — use in controllers where failure means 404 - Never use bang functions (
Repo.insert!,Repo.update!) inside context logic — crashes instead of returning errors. Use in controllers only, or pattern match on{:ok, _} | {:error, _} - Group related schemas:
MyApp.Accounts.User,MyApp.Accounts.Credential - Non-Ecto structs are valid domain models — use plain
defstructfor computed/assembled data not backed by a table
God Context Smell
A context exceeding ~500 lines or handling 4+ distinct responsibilities needs splitting. Symptoms:
- Context has 30+ public functions
- Functions within it don't share data or schemas
- You need to read the whole module to understand one feature
# BAD: God context — videos, channels, thumbnails, transcoding, subtitles all in one
defmodule MyApp.Library do # 850 lines
def list_videos(...), do: ...
def get_channel!(...), do: ...
def generate_thumbnail(...), do: ...
def transmux_to_hls(...), do: ...
def create_subtitle(...), do: ...
end
# GOOD: Split by responsibility
defmodule MyApp.Videos do ... end # CRUD, queries, lifecycle
defmodule MyApp.Channels do ... end # Channel profiles, derived from users
defmodule MyApp.Transcoding do ... end # FFmpeg, HLS, thumbnails
Query Composition Helpers
When the same joins/selects appear in 3+ queries, extract a shared helper:
# BAD: Copy-pasted select_merge across 10 queries
def list_videos do
from(v in Video,
join: u in User, on: v.user_id == u.id,
select_merge: %{channel_name: u.name, channel_handle: u.handle})
|> Repo.all()
end
# GOOD: Shared query builder
defp with_channel(query) do
from(v in query,
join: u in User, as: :user, on: v.user_id == u.id,
select_merge: %{channel_name: u.name, channel_handle: u.handle})
end
def list_videos, do: Video |> with_channel() |> Repo.all()
def get_video!(id), do: Video |> with_channel() |> Repo.get!(id)
Soft Deletes
Mark records as deleted instead of removing them:
# Schema
field :deleted_at, :utc_datetime
# Query helper
def not_deleted(query \\ __MODULE__) do
from(q in query, where: is_nil(q.deleted_at))
end
# Context — soft delete
def delete_video(%Video{} = video) do
video |> Ecto.Changeset.change(%{deleted_at: DateTime.utc_now()}) |> Repo.update()
end
# Always filter: Video |> Video.not_deleted() |> Repo.all()
HMAC-Signed Public URLs
For tamper-proof public URLs (unsubscribe links, tracking pixels, magic links):
def sign_url(data, secret_key_base) do
signature = :crypto.mac(:hmac, :sha256, secret_key_base, data) |> Base.url_encode64(padding: false)
"#{data}/#{signature}"
end
def verify_url(data, signature, secret_key_base) do
expected = :crypto.mac(:hmac, :sha256, secret_key_base, data) |> Base.url_encode64(padding: false)
Plug.Crypto.secure_compare(expected, signature)
end
# Usage: /unsubscribe/:contact_id/:hmac
Ecto.Multi for Safe Concurrent Operations
Use Ecto.Multi with row-level locking for operations that must be atomic under concurrency (e.g., reordering lists):
def update_position(%Song{} = song, new_position) do
Ecto.Multi.new()
|> Ecto.Multi.run(:lock, fn repo, _ ->
{:ok, repo.one(from s in Song, where: s.id == ^song.id, lock: "FOR UPDATE")}
end)
|> Ecto.Multi.run(:reorder, fn repo, %{lock: locked} ->
# Shift other items and update position
repo.update(Song.changeset(locked, %{position: new_position}))
end)
|> Repo.transaction()
end
Event Structs for PubSub
For complex apps, use dedicated event structs instead of bare tuples — provides compile-time checking and self-documenting payloads:
# In context module
defmodule MyApp.MediaLibrary.Events do
defmodule Play do
defstruct [:song, :elapsed]
end
defmodule SongsImported do
defstruct [:count, :user_id]
end
end
# Broadcasting
Phoenix.PubSub.broadcast(MyApp.PubSub, "media:#{user_id}", %Play{song: song, elapsed: 0})
# Receiving — pattern match on struct
def handle_info(%Play{song: song}, socket) do
{:noreply, assign(socket, now_playing: song)}
end
Context with Filtering and Pagination
def list_users(filters \\ %{}, opts \\ []) do
page = Keyword.get(opts, :page, 1)
per_page = Keyword.get(opts, :per_page, 20)
User
|> apply_filters(filters)
|> limit(^per_page)
|> offset(^((page - 1) * per_page))
|> Repo.all()
end
defp apply_filters(query, filters) do
Enum.reduce(filters, query, fn
{"role", role}, q -> where(q, [u], u.role == ^role)
{"search", term}, q -> where(q, [u], ilike(u.name, ^"%#{term}%"))
_, q -> q
end)
end
HEEx Templates & Components
Function Components
attr :name, :string, required: true
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block
def greet(assigns) do
~H"""
<div class={@class} {@rest}>
Hello, {@name}!
{render_slot(@inner_block)}
</div>
"""
end
Attrs and Slots
attr :user, :map, required: true
attr :size, :string, default: "md", values: ["sm", "md", "lg"]
attr :rest, :global
slot :header
slot :col do
attr :label, :string, required: true
end
Phoenix 1.8 HEEx Rules
- Always use
~Hsigil or.html.heexfiles — never~E - Always use
{...}for interpolation in attributes - Always use
<%= %>for control flow blocks (if/for/cond) - Never use
<% Enum.each %>— use<%= for item <- @items do %> - Class lists must use
[...]syntax for conditionals:
<a class={[
"px-2 text-white",
@active && "bg-blue-500",
if(@large, do: "text-lg", else: "text-sm")
]}>
- No
else if— usecond:
<%= cond do %>
<% @status == :pending -> %>
<span class="text-yellow-500">Pending</span>
<% @status == :approved -> %>
<span class="text-green-500">Approved</span>
<% true -> %>
<span class="text-gray-500">Unknown</span>
<% end %>
- Comments:
<%!-- comment --%> - Wrap LiveView templates with
<Layouts.app flash={@flash} ...> - Use
<.icon>for Heroicons,<.input>fromcore_components.ex Phoenix.Viewis obsolete — don't use it
embed_templates
defmodule MyAppWeb.Components do
use Phoenix.Component
embed_templates "components/*" # Loads .html.heex files as functions
end
HTTP Requests
Use :req library — not :httpoison, :tesla, or :httpc:
# deps: {:req, "~> 0.5"}
Req.get!("https://api.example.com/data")
Req.post!("https://api.example.com/users", json: %{name: "John"})
Channels & PubSub
Channel Architecture
One channel server process per client per topic. Broadcasts work across clustered nodes via PubSub.
defmodule MyAppWeb.RoomChannel do
use Phoenix.Channel
def join("room:" <> room_id, _params, socket) do
{:ok, assign(socket, :room_id, room_id)}
end
def handle_in("new_msg", %{"body" => body}, socket) do
broadcast!(socket, "new_msg", %{body: body})
{:noreply, socket}
end
end
PubSub Broadcasting
# Subscribe
Phoenix.PubSub.subscribe(MyApp.PubSub, "users:#{user_id}")
# Broadcast
Phoenix.PubSub.broadcast(MyApp.PubSub, "users:#{user_id}", {:user_updated, user})
# In LiveView handle_info
def handle_info({:user_updated, user}, socket) do
{:noreply, assign(socket, :user, user)}
end
Presence Tracking
defmodule MyAppWeb.Presence do
use Phoenix.Presence,
otp_app: :my_app,
pubsub_server: MyApp.PubSub
end
Presence.track(socket, user.id, %{online_at: System.system_time(:second)})
Presence.list(socket)
Forms & Validation
Controller Pattern
def new(conn, _params) do
changeset = Accounts.change_user(%User{})
render(conn, :new, changeset: changeset)
end
def create(conn, %{"user" => user_params}) do
case Accounts.create_user(user_params) do
{:ok, user} ->
conn
|> put_flash(:info, "User created successfully.")
|> redirect(to: ~p"/users/#{user}")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :new, changeset: changeset)
end
end
Changeset Validation
def changeset(user, attrs) do
user
|> cast(attrs, [:email, :password])
|> validate_required([:email, :password])
|> validate_format(:email, ~r/@/)
|> validate_length(:password, min: 8)
|> unique_constraint(:email)
end
Ecto Guidelines
- Always preload associations accessed in templates
- Use
:stringtype for all text columns (including text) validate_number/2skips nil automatically — no:allow_niloption- Must use
Ecto.Changeset.get_field/2to access changeset fields - Programmatic fields like
user_idshouldn't be incast/3— set explicitly:
# BAD - user_id from untrusted input
cast(attrs, [:body, :user_id]) # Security risk!
# GOOD - set programmatically
cast(attrs, [:body]) |> put_assoc(:user, user)
Upsert (Find or Create)
Use on_conflict for idempotent inserts — common for OAuth user sync, counters, external data:
def upsert_user(attrs) do
%User{}
|> User.changeset(attrs)
|> Repo.insert(
on_conflict: {:replace_all_except, [:id, :email, :inserted_at]},
conflict_target: :email,
returning: true
)
end
returning: true ensures the upserted record (with DB-generated fields) is returned.
Schemas in Migrations — NEVER
# BAD: Migration breaks if User schema changes
alias MyApp.Accounts.User
from(u in User, where: u.old_field == "value") |> Repo.update_all(...)
# GOOD: Use raw SQL
execute("UPDATE users SET permission = 'default' WHERE old_field = 'value'")
# GOOD: Or migration-local schema
defmodule User do
use Ecto.Schema
schema "users" do
field :old_field, :string
field :permission, :string
end
end
Security
CSRF Protection
- Enabled by default via
protect_from_forgeryplug - Token passed via params (
_csrf_token) or header (x-csrf-token) - Call
delete_csrf_token/0after login to prevent fixation attacks
Session Security
plug Plug.Session,
store: :cookie,
key: "_my_app_key",
signing_salt: "secret",
same_site: "Lax",
http_only: true,
secure: true,
max_age: 60 * 60 * 24 * 7 # 7 days
WebSocket Origin Protection
check_origin prevents Cross-site WebSocket Hijacking. Phoenix checks origin against configured host automatically:
# Recommended: use endpoint host
config :my_app, MyAppWeb.Endpoint, url: [host: "my-production-domain.com"]
# Explicit whitelist for multiple domains
socket "/live", Phoenix.LiveView.Socket,
websocket: [check_origin: [
"https://example.com",
"https://staging.example.com"
]]
Never set check_origin: false in production.
Mass Assignment Prevention
# BAD: User can set admin flag
cast(attrs, [:username, :email, :admin])
# GOOD: Separate changesets
def registration_changeset(user, attrs) do
cast(attrs, [:username, :email])
end
def admin_changeset(user, attrs) do
cast(attrs, [:admin, :role])
end
SQL Injection Prevention
# BAD: String interpolation in SQL
query = "SELECT * FROM users WHERE name = '#{params["name"]}'"
# GOOD: Ecto parameterization
from(u in User, where: u.name == ^params["name"]) |> Repo.all()
# GOOD: Raw SQL with parameters
Ecto.Adapters.SQL.query!(Repo, "SELECT * FROM users WHERE name = $1", [params["name"]])
XSS Prevention
# BAD: raw/1 on user input
<%= raw(@user_input) %>
# GOOD: Let Phoenix auto-escape (default)
<%= @user_input %>
# GOOD: If HTML needed, sanitize first
<%= raw(HtmlSanitizeEx.strip_tags(@user_input)) %>
Sobelow Security Scanner
mix archive.install hex sobelow
mix sobelow # Detects XSS, SQL injection, command injection, directory traversal
Security Mistakes (Atoms)
# BAD: User input to atom — atom table exhaustion!
role = String.to_atom(params["role"])
# GOOD: Validate against known values
role = case params["role"] do
"admin" -> :admin
"user" -> :user
_ -> :guest
end
Authentication
Generated Auth (phx.gen.auth)
mix phx.gen.auth Accounts User users # Session-based auth scaffolding
Generated code uses a Scope-based, token-based pattern:
- Session stores
:user_token(not user ID) — enables per-session revocation and token rotation live_socket_idis"users_sessions:#{Base.url_encode64(token)}"— token-based, not user-id-basedcurrent_scopeassign wraps user in aScopestruct — access user via@current_scope.useron_mounthooks::mount_current_scope(optional auth),:require_authenticated(required auth),:require_sudo_mode- Automatic token rotation after
@session_reissue_age_in_days(default 7) renew_session/2is a private function combiningdelete_csrf_token(),configure_session(renew: true), andclear_session()
# Generated on_mount pattern — uses assign_new to avoid re-fetching
def on_mount(:require_authenticated, _params, session, socket) do
socket = mount_current_scope(socket, session)
if socket.assigns.current_scope && socket.assigns.current_scope.user do
{:cont, socket}
else
{:halt, socket |> put_flash(:error, "You must log in") |> redirect(to: ~p"/users/log-in")}
end
end
defp mount_current_scope(socket, session) do
assign_new(socket, :current_scope, fn ->
if token = session["user_token"] do
{user, _} = Accounts.get_user_by_session_token(token)
Scope.for_user(user)
end || Scope.for_user(nil)
end)
end
JWT Auth via Guardian (JSON APIs)
phx.gen.auth is session + cookie — wrong fit for stateless JSON APIs that send Authorization: <something> <token> from a JS client, mobile app, or external consumer. For JWT auth in a Phoenix JSON API, use Guardian ({:guardian, "~> 2.3"}). It's the de-facto standard, has been in production use across thousands of apps for ~10 years, and handles every piece you would otherwise hand-roll: signing, verification, claims, header parsing (with custom schemes), pipeline plugs, token rotation/revocation hooks.
Decision — when to use what:
| Need | Use |
|---|---|
| Browser-driven app, server-rendered or LiveView, login form posts back | phx.gen.auth (sessions + cookies) |
| JSON API consumed by SPA, mobile, external clients; bearer-style token | Guardian |
| Both (web app + first-party API) | phx.gen.auth for the web layer + Guardian for the API |
| Cross-service / federated auth | OAuth provider (Auth0, Keycloak, AWS Cognito) — not Guardian |
| Hand-roll JWT signing because "the spec uses a custom header scheme" | No. Guardian's VerifyHeader takes a scheme: option (see below). |
Module setup — implement the Guardian behaviour for your User struct:
# lib/my_app/accounts/guardian.ex
defmodule MyApp.Accounts.Guardian do
use Guardian, otp_app: :my_app
alias MyApp.Accounts
alias MyApp.Accounts.User
@impl Guardian
def subject_for_token(%User{id: id}, _claims), do: {:ok, to_string(id)}
def subject_for_token(_, _), do: {:error, :invalid_resource}
@impl Guardian
def resource_from_claims(%{"sub" => id}) do
case Accounts.fetch_user(id) do
{:ok, user} -> {:ok, user}
{:error, :not_found} -> {:error, :resource_not_found}
end
end
def resource_from_claims(_), do: {:error, :invalid_claims}
end
Config — secret_key per environment:
# config/config.exs (no secret here)
config :my_app, MyApp.Accounts.Guardian,
issuer: "my_app",
ttl: {7, :days}
# config/test.exs — fixed test secret, short TTL
config :my_app, MyApp.Accounts.Guardian,
secret_key: "test-only-guardian-secret-32-bytes-or-more-xxxxxxxxxxxxx",
ttl: {1, :hour}
# config/runtime.exs — production secret from env, fail loudly if absent
if config_env() == :prod do
config :my_app, MyApp.Accounts.Guardian,
secret_key:
System.get_env("GUARDIAN_SECRET_KEY") ||
raise "GUARDIAN_SECRET_KEY missing — generate with `mix guardian.gen.secret`"
end
Pipeline — including custom Authorization scheme:
The VerifyHeader plug accepts a scheme: option. Custom header schemes (e.g. Authorization: Token <jwt> instead of Bearer <jwt>) are a one-line library config — never a reason to hand-roll a header-parsing plug.
# lib/my_app_web/auth/pipeline.ex
defmodule MyAppWeb.Auth.Pipeline do
use Guardian.Plug.Pipeline,
otp_app: :my_app,
module: MyApp.Accounts.Guardian,
error_handler: MyAppWeb.Auth.ErrorHandler
# Default scheme is "Bearer". Override for non-standard specs.
plug Guardian.Plug.VerifyHeader, scheme: "Token"
plug Guardian.Plug.LoadResource, allow_blank: true
plug MyAppWeb.Auth.AssignCurrentUser
end
defmodule MyAppWeb.Auth.AssignCurrentUser do
@moduledoc false
import Plug.Conn
def init(opts), do: opts
def call(conn, _), do: assign(conn, :current_user, Guardian.Plug.current_resource(conn))
end
Error handler — JSON shape for unauthenticated requests:
defmodule MyAppWeb.Auth.ErrorHandler do
@behaviour Guardian.Plug.ErrorHandler
import Plug.Conn
@impl Guardian.Plug.ErrorHandler
def auth_error(conn, {_type, _reason}, _opts) do
body = Jason.encode!(%{errors: %{body: ["unauthorized"]}})
conn
|> put_resp_content_type("application/json")
|> send_resp(401, body)
end
end
Require-user plug — for routes that need a logged-in resource:
defmodule MyAppWeb.Auth.RequireUser do
import Plug.Conn
alias MyApp.Accounts.User
def init(opts), do: opts
def call(%Plug.Conn{assigns: %{current_user: %User{}}} = conn, _), do: conn
def call(conn, _) do
body = Jason.encode!(%{errors: %{body: ["unauthorized"]}})
conn |> put_resp_content_type("application/json") |> send_resp(401, body) |> halt()
end
end
Router wiring — two pipelines (optional auth + required auth):
pipeline :optional_auth_api do
plug :accepts, ["json"]
plug MyAppWeb.Auth.Pipeline
end
pipeline :authenticated_api do
plug :accepts, ["json"]
plug MyAppWeb.Auth.Pipeline
plug MyAppWeb.Auth.RequireUser
end
scope "/api", MyAppWeb do
pipe_through :api
post "/users", UserController, :create # public — register
post "/users/login", UserController, :login # public — login
end
scope "/api", MyAppWeb do
pipe_through :authenticated_api
get "/user", UserController, :current
put "/user", UserController, :update
end
Issuing tokens — at register, login, and any time you need a fresh one:
def login(conn, %{"user" => %{"email" => email, "password" => pw}}) do
with {:ok, user} <- Accounts.authenticate(email, pw),
{:ok, token, _claims} <- MyApp.Accounts.Guardian.encode_and_sign(user) do
render(conn, :show, user: user, token: token)
end
end
Test helper — generate tokens directly without going through HTTP:
# In test/support/conn_case.ex
def authed_conn(conn, user) do
{:ok, token, _claims} = MyApp.Accounts.Guardian.encode_and_sign(user)
put_req_header(conn, "authorization", "Token #{token}")
end
# In a controller test
test "200 with current user", %{conn: conn} do
user = user_fixture()
conn = authed_conn(conn, user) |> get(~p"/api/user")
assert json_response(conn, 200)["user"]["email"] == user.email
end
Token revocation — when you need it:
By default Guardian tokens are stateless: revocation by deletion isn't possible without checking a store. Add {:guardian_db, "~> 3.0"} to track issued tokens in Postgres and Guardian.revoke/1 them on logout. Most JSON APIs skip this and rely on short TTLs + reissue.
Anti-patterns:
# BAD — hand-rolled JWT plug because the spec uses "Token" not "Bearer"
defmodule MyAppWeb.Plugs.JwtAuth do
def call(conn, _) do
case get_req_header(conn, "authorization") do
["Token " <> token] ->
case MyApp.Token.verify(token) do # also hand-rolled
{:ok, user_id} -> assign(conn, :current_user, MyApp.Accounts.get_user!(user_id))
_ -> conn |> send_resp(401, "") |> halt()
end
_ -> conn |> send_resp(401, "") |> halt()
end
end
end
# GOOD — Guardian's VerifyHeader takes the scheme as an option
plug Guardian.Plug.VerifyHeader, scheme: "Token"
# BAD — using Joken (raw JWT primitives) directly, then writing a plug around it
defmodule MyApp.Token do
use Joken.Config
def sign(user), do: ...
def verify(token), do: ...
end
# GOOD — Guardian wraps Joken, gives you a behaviour, plug pipeline, and
# revocation hooks. Joken alone forces you to rebuild all of that.
use Guardian, otp_app: :my_app
OAuth Authentication Flow
For GitHub/Google/etc. OAuth:
# Router
scope "/auth", MyAppWeb do
pipe_through [:browser]
get "/:provider", AuthController, :request
get "/:provider/callback", OAuthCallbackController, :new
end
# Callback controller — with-chain for multi-step auth
defmodule MyAppWeb.OAuthCallbackController do
use MyAppWeb, :controller
def new(conn, %{"provider" => "github", "code" => code}) do
with {:ok, token} <- GitHub.exchange_code(code),
{:ok, info} <- GitHub.fetch_user_info(token),
{:ok, user} <- Accounts.register_or_login_github_user(info, token) do
UserAuth.log_in_user(conn, user)
else
{:error, reason} ->
conn |> put_flash(:error, "Auth failed: #{reason}") |> redirect(to: ~p"/")
end
end
end
live_socket_id for Multi-Tab Session Management
Set a live_socket_id on login to force-disconnect all LiveView tabs on logout:
def log_in_user(conn, user) do
token = Accounts.generate_user_session_token(user)
conn
|> renew_session(user)
|> put_session(:user_token, token)
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
|> redirect(to: ~p"/")
end
def log_out_user(conn) do
token = get_session(conn, :user_token)
token && Accounts.delete_user_session_token(token)
if live_socket_id = get_session(conn, :live_socket_id) do
MyAppWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
end
conn |> renew_session(nil) |> redirect(to: ~p"/")
end
# renew_session is a private function — not a Plug built-in
defp renew_session(conn, _user) do
delete_csrf_token()
conn |> configure_session(renew: true) |> clear_session()
end
Policy-Based Authorization
For complex role/permission models, use dynamic policy dispatch:
defmodule MyAppWeb.Plugs.Authorize do
import Plug.Conn
import Phoenix.Controller
def init(opts), do: opts
def call(conn, policy_module) do
user = conn.assigns.current_user
if apply(policy_module, action_name(conn), [user]) do
conn
else
conn |> put_flash(:error, "Not authorized") |> redirect(to: ~p"/") |> halt()
end
end
end
# Policy module — multi-clause functions for each action
defmodule MyApp.Policies.Post do
def index(_user), do: true
def create(%{role: role}) when role in [:admin, :editor], do: true
def create(_), do: false
def delete(%{role: :admin}), do: true
def delete(_), do: false
end
# In controller: plug MyAppWeb.Plugs.Authorize, MyApp.Policies.Post
External API Resilience
Circuit Breaker (fuse)
Protect against cascading failures when calling external APIs:
# deps: {:fuse, "~> 2.5"}
# Install the fuse (in Application.start or context init)
:fuse.install(:tesla_api, {{:standard, 5, 60_000}, {:reset, 300_000}})
# 5 failures in 60s → blow fuse, reset after 5 minutes
# In context — check fuse before calling
def fetch_vehicle_data(vehicle_id) do
case :fuse.ask(:tesla_api, :sync) do
:ok ->
case TeslaClient.get_vehicle(vehicle_id) do
{:ok, data} -> {:ok, data}
{:error, :unauthorized} ->
:fuse.melt(:tesla_api) # Record failure
{:error, :unauthorized}
end
:blown ->
{:error, :api_unavailable}
end
end
ETS as Read Cache
For hot data read by many concurrent processes (auth tokens, config), use ETS with GenServer write serialization:
defmodule MyApp.TokenCache do
use GenServer
def start_link(_) do
GenServer.start_link(__MODULE__, nil, name: __MODULE__)
end
def init(_) do
table = :ets.new(__MODULE__, [:set, :named_table, :public, read_concurrency: true])
{:ok, table}
end
# Fast concurrent reads — no GenServer bottleneck
def get_token(user_id) do
case :ets.lookup(__MODULE__, user_id) do
[{^user_id, token}] -> {:ok, token}
[] -> {:error, :not_found}
end
end
# Writes serialized through GenServer
def put_token(user_id, token) do
GenServer.call(__MODULE__, {:put, user_id, token})
end
def handle_call({:put, user_id, token}, _from, table) do
:ets.insert(table, {user_id, token})
{:reply, :ok, table}
end
end
NimbleOptions for Init Validation
Validate GenServer/Plug options at startup — fail fast with clear errors:
# deps: {:nimble_options, "~> 1.1"}
defmodule MyApp.Poller do
use GenServer
@opts_schema [
interval: [type: :pos_integer, default: 5000, doc: "Poll interval in ms"],
url: [type: :string, required: true, doc: "API endpoint URL"],
max_retries: [type: :non_neg_integer, default: 3]
]
def start_link(opts) do
opts = NimbleOptions.validate!(opts, @opts_schema)
GenServer.start_link(__MODULE__, opts)
end
end
# Clear error on bad config:
# ** (NimbleOptions.ValidationError) invalid value for :interval option:
# expected positive integer, got: -1
Production Plugs
Webhook Signature Verification (Pre-Parser)
Webhook providers (Stripe, GitHub) require verifying signatures against the raw request body. Place before Plug.Parsers in endpoint:
# In endpoint.ex — BEFORE Plug.Parsers
plug MyAppWeb.Plugs.WebhookBody
defmodule MyAppWeb.Plugs.WebhookBody do
import Plug.Conn
def init(opts), do: opts
def call(%{request_path: "/webhooks/" <> _} = conn, _opts) do
{:ok, raw_body, conn} = read_body(conn)
assign(conn, :raw_body, raw_body)
end
def call(conn, _opts), do: conn
end
Global Data Preloading
Preload data needed on every page (navigation, site config):
defmodule MyAppWeb.Plugs.LoadNavData do
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts), do: assign(conn, :nav_categories, MyApp.Catalog.cached_categories())
end
# In pipeline: plug MyAppWeb.Plugs.LoadNavData
Background Jobs (Oban)
Most production Phoenix apps need background job processing. Oban is the standard:
# deps: {:oban, "~> 2.20"}
# config.exs
config :my_app, Oban,
repo: MyApp.Repo,
queues: [default: 10, mailer: 50, media: 5],
plugins: [
{Oban.Plugins.Pruner, max_age: 60 * 60 * 24 * 7},
{Oban.Plugins.Cron, crontab: [
{"0 * * * *", MyApp.Workers.HourlyCleanup},
{"*/5 * * * *", MyApp.Workers.CheckScheduled}
]}
]
Worker Pattern
defmodule MyApp.Workers.SendEmail do
use Oban.Worker, queue: :mailer, max_attempts: 3, unique: [period: 300]
@impl Oban.Worker
def perform(%Oban.Job{args: %{"user_id" => user_id, "template" => template}}) do
user = Accounts.get_user!(user_id)
case Mailer.deliver(user, template) do
{:ok, _} -> :ok
{:error, :rate_limited} -> {:snooze, 60} # Retry after 60 seconds
{:error, reason} -> {:error, reason} # Will retry with backoff
end
end
end
# Enqueue
%{user_id: user.id, template: "welcome"}
|> MyApp.Workers.SendEmail.new()
|> Oban.insert()
# Schedule for later
%{user_id: user.id, template: "reminder"}
|> MyApp.Workers.SendEmail.new(scheduled_at: DateTime.add(DateTime.utc_now(), 3600))
|> Oban.insert()
Key Patterns
- Return
:okon success,{:error, reason}to retry,{:snooze, seconds}to delay - Use
unique: [period: 300, keys: [:user_id]]to prevent duplicate jobs - Use
{:cancel, reason}to permanently fail without retrying - Test with
Oban.Testing—assert_enqueued worker: MyWorker, args: %{user_id: 1}
Multi-Tenancy
Project-Based Isolation
Common pattern: Users → Accounts → Projects, with all data scoped to a project:
# Plug loads project and verifies access
defmodule MyAppWeb.Plugs.LoadProject do
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
project_id = conn.path_params["project_id"]
user = conn.assigns.current_user
case Projects.get_project_for_user(project_id, user.id) do
%Project{} = project -> assign(conn, :current_project, project)
nil -> conn |> put_status(404) |> halt() # 404 not 403 — don't leak existence
end
end
end
# Scope all queries to project
def list_contacts(%Project{} = project) do
from(c in Contact, where: c.project_id == ^project.id) |> Repo.all()
end
Key: Return 404 (not 403) when a user accesses another user's project — prevents resource enumeration.
Compile-Time Feature Flags
For open-source + hosted versions from a single codebase:
defmodule MyApp do
defmacro if_cloud(do: block) do
if Code.ensure_loaded?(MyAppCloud) do
block
end
end
def __mix_recompile__?, do: Code.ensure_loaded?(MyAppCloud) != @cloud_loaded
end
# In router — cloud-only routes
MyApp.if_cloud do
scope "/billing", MyAppWeb.Cloud do
live "/plans", PlansLive
end
end
Configuration Precedence
Config files load in order — later overrides earlier:
1. config/config.exs — compile-time, all envs
2. config/{dev,test,prod}.exs — compile-time, per-env
3. config/runtime.exs — runtime, ALL envs (!)
CRITICAL:
runtime.exsruns in every environment. Always guard:
# BAD: Overrides dev.exs/test.exs port!
config :my_app, MyAppWeb.Endpoint, http: [port: 4000]
# GOOD: Only in prod
if config_env() == :prod do
config :my_app, MyAppWeb.Endpoint,
http: [port: String.to_integer(System.get_env("PORT", "4000"))]
end
Troubleshooting :eaddrinuse: Check runtime.exs for unconditional endpoint config overriding dev.exs/test.exs port settings.
Internationalization (Gettext)
Setup
# In MyAppWeb.Gettext (auto-generated)
use Gettext.Backend, otp_app: :my_app
# In templates
{gettext("Hello")}
{ngettext("1 item", "%{count} items", @count)}
{dgettext("errors", "is invalid")}
# Extract and merge translations
mix gettext.extract
mix gettext.merge priv/gettext
Locale Plug
Set locale per request from user preference, Accept-Language header, or URL:
defmodule MyAppWeb.Plugs.SetLocale do
import Plug.Conn
@supported_locales ~w(en de es fr)
def init(opts), do: opts
def call(conn, _opts) do
locale =
conn.params["locale"] ||
get_session(conn, :locale) ||
parse_accept_language(conn) ||
"en"
if locale in @supported_locales do
Gettext.put_locale(MyAppWeb.Gettext, locale)
conn |> put_session(:locale, locale)
else
conn
end
end
defp parse_accept_language(conn) do
case get_req_header(conn, "accept-language") do
[header | _] -> header |> String.split(",") |> hd() |> String.split("-") |> hd()
_ -> nil
end
end
end
# In pipeline: plug MyAppWeb.Plugs.SetLocale
LiveView Locale
LiveView doesn't have access to the conn after mount. Pass locale via connect params:
// app.js
let liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken, locale: document.documentElement.lang}
})
# In on_mount hook
def on_mount(:set_locale, _params, _session, socket) do
locale = get_connect_params(socket)["locale"] || "en"
Gettext.put_locale(MyAppWeb.Gettext, locale)
{:cont, assign(socket, :locale, locale)}
end
GraphQL (Absinthe)
For GraphQL APIs alongside or instead of REST, use Absinthe. Brief overview — deep coverage is out of scope for this skill:
# Router — GraphQL endpoint alongside REST
scope "/" do
pipe_through :api
forward "/graphql", Absinthe.Plug, schema: MyAppWeb.Schema
forward "/graphiql", Absinthe.Plug.GraphiQL, schema: MyAppWeb.Schema
end
# Schema (lib/my_app_web/schema.ex)
defmodule MyAppWeb.Schema do
use Absinthe.Schema
import_types MyAppWeb.Schema.UserTypes
query do
field :user, :user do
arg :id, non_null(:id)
resolve &MyAppWeb.Resolvers.Users.get/3
end
end
end
Key patterns: Use Dataloader for N+1 prevention, middleware for auth, separate mutation objects with import_fields. For file operations that don't fit GraphQL well (multipart uploads), use REST controllers alongside.
Tailwind CSS Integration
Phoenix 1.8 / Tailwind v4
/* app.css */
@import "tailwindcss";
No tailwind.config.js needed in Tailwind v4.
Guidelines
- Never use
@applyin CSS (per Phoenix AGENTS.md) - Only
app.jsandapp.cssbundles are supported - Never use external script
src— import into app.js
DaisyUI Setup
cd assets && npm install -D daisyui@latest
@import "tailwindcss";
@plugin "daisyui";
Deployment
Mix Release
mix phx.gen.release --docker # Generate Dockerfile
MIX_ENV=prod mix release # Build release
Fly.io
fly launch # Detects Phoenix, creates Dockerfile
fly deploy # Deploy changes
fly ssh console # Remote IEx shell
fly secrets set KEY=value
Production Configuration
# runtime.exs
if config_env() == :prod do
config :my_app, MyAppWeb.Endpoint,
url: [host: System.get_env("PHX_HOST"), port: 443, scheme: "https"],
secret_key_base: System.fetch_env!("SECRET_KEY_BASE")
end
Release Migrations
defmodule MyApp.Release do
def migrate do
Application.ensure_all_started(:my_app)
Ecto.Migrator.run(MyApp.Repo, :up, all: true)
end
end
# ./bin/my_app eval "MyApp.Release.migrate()"
Generators
Always prefer generators over hand-writing — they produce correct, consistent code with migrations, tests, and context functions already wired up.
Which Generator?
| When you need to... | Use this | What it creates |
|---|---|---|
| Full CRUD with HTML pages | mix phx.gen.html |
Context, schema, migration, controller, templates, tests |
| JSON API endpoints | mix phx.gen.json |
Context, schema, migration, controller, JSON view, tests |
| LiveView CRUD | mix phx.gen.live |
Context, schema, migration, LiveView, components, tests |
| Business logic only (no web) | mix phx.gen.context |
Context, schema, migration, context tests |
| Schema + migration only | mix phx.gen.schema |
Schema module, migration |
| Embedded schema (no table) | mix phx.gen.embedded |
Schema module only (no migration) |
| Session auth (login/register) | mix phx.gen.auth |
Full auth system: context, schema, plugs, controllers, LiveViews, tests, migration |
| Channel (WebSocket) | mix phx.gen.channel |
Channel module, socket setup, JS client |
| Presence tracking | mix phx.gen.presence |
Presence module |
| Email/SMS notifications | mix phx.gen.notifier |
Notifier module with email functions |
| Release + Dockerfile | mix phx.gen.release --docker |
Dockerfile, release.ex, server script |
Generator Examples
# Full HTML CRUD — creates Accounts context, User schema, controller, templates
mix phx.gen.html Accounts User users \
name:string email:string:unique age:integer role:enum:admin:user
# JSON API — same but with JSON views instead of HTML
mix phx.gen.json Blog Post posts \
title:string body:text published:boolean user_id:references:users
# LiveView CRUD — generates LiveView modules instead of controllers
mix phx.gen.live Catalog Product products \
name:string price:decimal description:text category_id:references:categories
# Context only — when adding to an existing context
mix phx.gen.context Accounts Credential credentials \
email:string:unique provider:string token:string user_id:references:users
# Schema only — when you need just the schema + migration
mix phx.gen.schema Accounts.Session sessions \
token:binary user_id:references:users
# Embedded schema — for nested data (no DB table)
mix phx.gen.embedded Accounts.Address \
street:string city:string zip:string country:string
# Auth — generates complete authentication system
mix phx.gen.auth Accounts User users
# Notifier
mix phx.gen.notifier Accounts
Field Types
# Basic types
name:string # varchar
body:text # text (unlimited)
age:integer # integer
price:decimal # numeric/decimal
active:boolean # boolean
data:binary # bytea/blob
metadata:map # jsonb
tags:array:string # text[]
# Date/Time
birth_date:date # date
start_time:time # time
published_at:utc_datetime # timestamp with timezone
updated_at:utc_datetime_usec # microsecond precision
# References (foreign keys)
user_id:references:users
category_id:references:categories
# Modifiers
email:string:unique # adds unique index
slug:string:unique:index # unique + additional index
position:integer:default:0
# Enums (Ecto 3.9+)
status:enum:pending:active:archived
role:enum:admin:user:guest
After Running a Generator
Generators print instructions — always follow them:
# 1. Add resource to router (generators tell you what to add)
# 2. Run migration
mix ecto.migrate
# 3. If adding to existing context, generators may skip the context file
# — check if functions were added or if you need to merge manually
Ecto Commands
# Database
mix ecto.create # Create database
mix ecto.drop # Drop database (destructive!)
mix ecto.reset # Drop + create + migrate + seed
# Migrations
mix ecto.gen.migration AddUserRole # Generate empty migration
mix ecto.migrate # Run pending migrations
mix ecto.rollback # Rollback last migration
mix ecto.rollback --step 3 # Rollback last 3 migrations
mix ecto.migrations # Show migration status
# Seeds
mix run priv/repo/seeds.exs # Run seed file
Migration Patterns
# Generate: mix ecto.gen.migration CreateProducts
defmodule MyApp.Repo.Migrations.CreateProducts do
use Ecto.Migration
def change do
create table(:products, primary_key: false) do
add :id, :binary_id, primary_key: true
add :name, :string, null: false
add :price, :decimal, precision: 10, scale: 2, null: false
add :status, :string, default: "draft"
add :metadata, :map, default: %{}
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
timestamps(type: :utc_datetime_usec)
end
create index(:products, [:user_id])
create unique_index(:products, [:name, :user_id])
end
end
Common Migration Operations
# Add column
alter table(:users) do
add :role, :string, default: "user"
end
# Remove column
alter table(:users) do
remove :legacy_field
end
# Rename column
rename table(:users), :name, to: :full_name
# Add index
create index(:posts, [:user_id, :published_at])
create unique_index(:users, [:email])
# Data migration (use raw SQL, never app schemas)
execute "UPDATE users SET role = 'admin' WHERE is_admin = true"
# Enum type (Postgres)
execute "CREATE TYPE user_role AS ENUM ('admin', 'user', 'guest')", "DROP TYPE user_role"
alter table(:users) do
add :role, :user_role, default: "user"
end
Server Commands
mix phx.server # Start dev server
iex -S mix phx.server # Start with IEx
mix phx.routes # List all routes
mix phx.routes MyAppWeb.Router # Specific router
mix phx.gen.secret # Generate secret key
mix phx.digest # Static asset digest (prod)
Local Development Infrastructure
Postgres via Docker Compose
Choose Docker over a system Postgres when: multiple Phoenix projects share the host, the project should run on a fresh machine without apt install postgresql ceremony, or CI uses the same image you do.
# compose.yml
services:
postgres:
image: postgres:17
container_name: my_app_postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: my_app_dev
ports:
- "5435:5432" # named port — avoid colliding with other projects
volumes:
- my_app_pg_data:/var/lib/postgresql/data
volumes:
my_app_pg_data:
# config/dev.exs + config/test.exs
config :my_app, MyApp.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
port: 5435, # match the host-side port from compose.yml
database: "my_app_dev"
Port discipline: pick a non-default port per project (5435, 5436, …). The container-internal port stays 5432; only the host-side mapping is named. Multiple Phoenix projects can run their compose up -d postgres in parallel without collision.
Bring the DB up BEFORE mix test on a fresh project. The default test alias is ["ecto.create --quiet", "ecto.migrate --quiet", "test"] — without a reachable DB it fails before any test runs, with a confusing connection error.
docker compose up -d postgres
mix test # alias runs ecto.create + ecto.migrate first
mix phx.gen.release --docker partial-failure recovery
The --docker generator fetches Erlang/Elixir image-tag metadata from Docker Hub at task time. When the upstream API 504s or rate-limits, the task aborts AFTER it has already created rel/, lib/<app>/release.ex, and the env wrappers — but BEFORE writing the Dockerfile and .dockerignore.
Recovery: the rel/ files don't need regeneration. Write the missing Dockerfile manually using a current hexpm/elixir base image:
ARG ELIXIR_VERSION=1.18.4
ARG OTP_VERSION=27.2
ARG DEBIAN_VERSION=bookworm-20250113-slim
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
FROM ${BUILDER_IMAGE} AS builder
# ... standard mix release builder stage
FROM ${RUNNER_IMAGE}
# ... standard runner stage with the release artifact
Cross-check against the latest Phoenix repo's installer/templates/phx_release/Dockerfile.eex for the exact shape.
Anti-Patterns (BAD/GOOD)
Context Bypass
# BAD: Accessing Repo directly in controller
def show(conn, %{"id" => id}) do
user = Repo.get!(User, id)
end
# GOOD: Use context
def show(conn, %{"id" => id}) do
user = Accounts.get_user!(id)
end
N+1 Queries
# BAD: Lazy load in loop
users = Accounts.list_users()
Enum.map(users, fn u -> u.department.name end) # N+1!
# GOOD: Preload
users = Accounts.list_users() |> Repo.preload(:department)
Router Namespace Duplication
# BAD: Redundant namespace
scope "/admin", MyAppWeb.Admin do
live "/users", MyAppWeb.Admin.UserLive
end
# GOOD: Scope provides alias
scope "/admin", MyAppWeb.Admin do
live "/users", UserLive
end
Template Deprecations
# BAD
~E"..." # Use ~H
Phoenix.HTML.form_for(...) # Use Phoenix.Component.form
<.form let={f}> # Use @form[:field]
<%= if x do %>...<% else if y do %> # No else if — use cond
Mix.env() in Runtime Code
# BAD: Mix.env() is not available in releases — crashes in production!
def send_notification(user) do
if Mix.env() == :prod do
Mailer.deliver(user)
end
end
# GOOD: Use compile-time config or Application.get_env
@env Application.compile_env(:my_app, :env)
def send_notification(user) do
if @env == :prod, do: Mailer.deliver(user)
end
# GOOD: Or runtime config
def send_notification(user) do
if Application.get_env(:my_app, :send_emails), do: Mailer.deliver(user)
end
Hardcoded Fallback Secrets
# BAD: Hardcoded secret ships in source control
secret_key_base: System.get_env("SECRET_KEY_BASE") || "hardcoded_fallback_value"
# GOOD: Fail loudly if secret is missing
secret_key_base: System.fetch_env!("SECRET_KEY_BASE")
rescue for Control Flow
# BAD: Using rescue to handle expected "not found" case
def on_mount(:ensure_authenticated, _params, session, socket) do
user = Accounts.get_user!(session["user_id"])
{:cont, assign(socket, current_user: user)}
rescue
Ecto.NoResultsError -> {:halt, redirect(socket, to: ~p"/login")}
end
# GOOD: Pattern match on nil — expected case, not exceptional
def on_mount(:ensure_authenticated, _params, session, socket) do
case Accounts.get_user(session["user_id"]) do
%User{} = user -> {:cont, assign(socket, current_user: user)}
nil -> {:halt, redirect(socket, to: ~p"/login")}
end
end
Bang Functions in Context Logic
# BAD: Crashes the process on failure
def process_video(%Video{} = video) do
updated = Repo.update!(Video.changeset(video, %{status: :processing})) # Crash!
Repo.insert!(Thumbnail.changeset(%{video_id: updated.id})) # Crash!
end
# GOOD: Return ok/error tuples, let caller decide
def process_video(%Video{} = video) do
Ecto.Multi.new()
|> Ecto.Multi.update(:video, Video.changeset(video, %{status: :processing}))
|> Ecto.Multi.insert(:thumbnail, fn %{video: v} ->
Thumbnail.changeset(%Thumbnail{}, %{video_id: v.id})
end)
|> Repo.transaction()
end
Process.sleep for Rate Limiting
# BAD: Blocks the GenServer process — no other messages processed during sleep
def handle_info(:geocode_batch, state) do
Enum.each(state.pending, fn addr ->
geocode(addr)
Process.sleep(1500) # Blocks for N * 1.5 seconds!
end)
{:noreply, state}
end
# GOOD: Self-scheduling with send_after — process remains responsive
def handle_info(:geocode_next, %{pending: [addr | rest]} = state) do
geocode(addr)
Process.send_after(self(), :geocode_next, 1500)
{:noreply, %{state | pending: rest}}
end
def handle_info(:geocode_next, %{pending: []} = state) do
{:noreply, state}
end
Context Coupling
# BAD: Contexts calling each other directly — creates hidden dependencies
defmodule MyApp.Settings do
def update_car_settings(car, attrs) do
# ...update...
MyApp.Vehicles.restart(car.id) # Tight coupling!
end
end
# GOOD: Use PubSub — contexts stay independent
defmodule MyApp.Settings do
def update_car_settings(car, attrs) do
# ...update...
Phoenix.PubSub.broadcast(MyApp.PubSub, "settings:#{car.id}", {:settings_changed, car.id})
end
end
# Vehicles subscribes and handles independently
def handle_info({:settings_changed, car_id}, state) do
{:noreply, restart_car(state, car_id)}
end
Overly Permissive CORS
# BAD: Allows any origin — credential/token theft via malicious sites
plug Corsica, origins: "*"
# GOOD: Explicit allowed origins
plug Corsica, origins: ["https://app.example.com", "https://staging.example.com"]
Mutating Config at Runtime
# BAD: Not safe in concurrent OTP — races between processes
Application.put_env(:my_app, :mailer, adapter: Swoosh.Adapters.SMTP)
# GOOD: Set config at startup in Application.start or runtime.exs
# If truly dynamic, use a GenServer or ETS table
Silencing Unexpected Messages
# BAD: Silently swallows messages — hides bugs
def handle_info(_msg, socket), do: {:noreply, socket}
# GOOD: Log unexpected messages
def handle_info(msg, socket) do
Logger.warning("Unexpected message in #{__MODULE__}: #{inspect(msg)}")
{:noreply, socket}
end
Supporting Files
- reference.md — Plug.Conn functions, router DSL, generator field types, channel patterns, context pattern reference, component attribute types, session configuration
- examples.md — Complete plug examples, context module template, channel implementation, controller examples, security configuration, component examples, anti-patterns gallery
Related Skills
- phoenix-liveview — LiveView lifecycle, components, forms, streams, async, hooks, JS commands. Key: LiveView builds on Phoenix — use contexts for data, PubSub for broadcasts.
- elixir — Pattern matching, pipelines, ok/error tuples, Ecto. Key: Phoenix controllers/contexts are Elixir modules — use multi-clause functions, not if/else chains.
- elixir-testing — ExUnit, Mox, ConnCase. Key: Phoenix controller tests use
ConnCase, LiveView tests usePhoenix.LiveViewTest. - tailwind — Utility classes, dark mode, responsive design. Key: Phoenix 1.8 uses Tailwind v4 — no config file needed.
- elixir-deployment — Mix releases, Docker, cloud providers, Kubernetes, production patterns.
- ash — Alternative to hand-written contexts: resources, domains, actions, policies. Key: Ash can replace Phoenix contexts with declarative resource definitions.