--- name: phoenix description: "Use when building an Elixir web app with Phoenix — contexts, Ecto schemas, changesets and migrations, LiveView, channels and PubSub, the generators, and the boundary between domain logic and the web layer. Covers the classic LiveView over-rendering and Ecto N+1 traps. NOT pure OTP work with no web or Ecto layer — GenServers, supervision trees, releases (that is `elixir`)." tags: [phoenix, elixir, liveview, ecto, channels, contexts, web, beam] recommends: [elixir, postgresdb, docker] origin: risco --- # Phoenix You are building a web application on the BEAM with Phoenix. There is one mental model that keeps a Phoenix app coherent as it grows: **contexts are the public API of your domain; the web layer (controllers, LiveViews, channels) is a thin caller of contexts.** A LiveView that reaches into `Repo` directly, or a controller stuffed with business rules, is the first crack. Push logic down into a context function and the web layer stays a presentation shell you can swap (HTML → JSON → LiveView) without rewriting the domain. Target the current stack: **Phoenix 1.8.7** (1.8.0 shipped 2025-08-05), **LiveView 1.1** (1.1.x patch line; 1.0 shipped 2024-12-03), **Ecto** as the data layer, **Erlang/OTP 25+**. The headline 1.8 change is **scopes**: generators thread the current actor (user/org) through every context function and into the query, so *secure-by-default data access is the norm, not something you bolt on later*. New apps ship daisyUI + Tailwind theming, a single root layout, and an `AGENTS.md` for LLM-assisted work. If the question has no web, Ecto or LiveView in it — it is a GenServer, a supervision tree, a `mix release` — that is the runtime, route to `../elixir/SKILL.md`. ## Where does this code go — generator decision Pick the layer first, then the generator. Getting this wrong means rewriting the boundary later. | You are building | Use | Generator | |---|---|---| | Stateless page or JSON endpoint, request→response | Controller + view | `mix phx.gen.html` / `phx.gen.json` | | Stateful interactive UI, server-rendered, live updates | LiveView | `mix phx.gen.live` | | Raw bidirectional WebSocket / fan-out to many clients | Channel + PubSub | hand-wire, no generator | | Domain logic with no UI yet (just the boundary) | Context only | `mix phx.gen.context` | | Login, sessions, password reset, scopes | Auth scaffold | `mix phx.gen.auth` | Rule: **generate the context first, then the web layer on top of it.** `phx.gen.live` and `phx.gen.html` already produce a context — don't hand-write a controller that calls `Repo` and skip the context. ## Contexts — the domain boundary A context is a module like `Accounts`, `Catalog`, `Orders` that owns a slice of the domain. The web layer calls `Catalog.list_products(scope)`; it never calls `Repo` and never builds an `Ecto.Query`. Rule: **the public contract is plain data and functions, not Ecto schemas.** Schemas are an implementation detail. Leak them and every caller couples to your column names. Why: you can refactor the table without touching controllers. Rule: **thread the scope through every context function** (1.8). The scope carries the current actor; the context filters every query by it. Why: a forgotten `where: user_id ==` is a data leak — scoping makes the safe path the default path. ```elixir # Bad — Repo + business logic in the controller, no scope, anyone reads anyone's data. def index(conn, _params) do products = Repo.all(Product) # raw Repo in web layer render(conn, :index, products: products) end # Good — controller calls a context function that takes the scope. def index(conn, _params) do products = Catalog.list_products(conn.assigns.current_scope) render(conn, :index, products: products) end # In lib/my_app/catalog.ex — the boundary owns the query and the scope filter. def list_products(%Scope{} = scope) do Product |> where(org_id: ^scope.org.id) # secure by default |> Repo.all() end ``` Depth on defining and threading scopes, magic-link auth, sudo mode and query-level enforcement lives in `references/auth-and-scopes.md`. ## Ecto — the data layer Ecto gives you `Repo`, schemas, `Ecto.Changeset` (cast + validate), `Ecto.Multi` (transactional pipelines) and `Ecto.Query`. Rule: **validate at the boundary with a changeset, never with `try/rescue`.** A changeset casts external params, applies validations and constraints, and hands you `{:ok, struct}` or `{:error, changeset}` you can render straight into a form. Why: validation errors are expected data flow, not exceptions. Rule: **multi-write operations go through `Ecto.Multi`** so they commit or roll back as a unit. Why: a half-written order with no payment row is corruption. Rule: **preload associations — never trigger a query per row.** Why: the N+1 is the single most common Phoenix performance bug. ```elixir # Bad — N+1: each post in the loop fires a separate query for post.author. posts = Repo.all(Post) for post <- posts, do: post.author.name # one SELECT per post # Good — one query for posts, one for all authors. posts = Post |> preload(:author) |> Repo.all() for post <- posts, do: post.author.name ``` Migrations are forward-only facts about schema history — write a new one, don't edit a shipped migration. Changeset recipes, `Ecto.Multi`, advanced queries/preloads, constraints and sandbox config are in `references/ecto-patterns.md`. ## LiveView — stateful server-rendered UI A LiveView holds state in `socket.assigns`, renders HEEx, and reacts to events. The lifecycle: | Callback | Fires when | Use it for | |---|---|---| | `mount/3` | First connect (and the dead render) | Load initial data, set up subscriptions | | `handle_params/3` | Mount and every live patch | React to URL/query changes | | `handle_event/3` | A client event (`phx-click`, form submit) | Mutate state, call a context | | `handle_info/2` | A message arrives (PubSub, `send/2`) | Apply external/async updates | Rule: **use streams for collections — do not hold the full list in an assign.** A stream keeps the list off the socket; `stream_insert/3` of one item sends only that item over the wire. Why: assigning the whole list re-sends and re-diffs every row on every change — that is exactly the "re-renders the entire list when one row is added" symptom. For per-row change tracking wrap each entry in a LiveComponent. ```elixir # Bad — full list in assigns; one insert re-diffs the entire collection. def mount(_p, _s, socket), do: {:ok, assign(socket, :messages, Chat.list_messages())} def handle_info({:new, msg}, socket) do {:noreply, assign(socket, :messages, socket.assigns.messages ++ [msg])} end # Good — stream; only the new row crosses the wire. def mount(_p, _s, socket), do: {:ok, stream(socket, :messages, Chat.list_messages())} def handle_info({:new, msg}, socket), do: {:noreply, stream_insert(socket, :messages, msg)} ``` ```heex
{msg.body}
``` Build forms with `to_form/2` (carry the changeset, render errors). Route with **verified routes** — the `~p` sigil (`~p"/products/#{product}"`) is compile-checked, the default since 1.7. LiveView 1.1 adds **colocated hooks** (`