Phoenix Framework skill for Claude Code
Scoped contexts and LiveViews that never leak rows. Claude keeps Phoenix apps layered: LiveViews and controllers call scoped context functions, ownership comes from the scope instead of params, and PubSub topics carry the tenant. It respects the double mount, uses streams for growing lists, writes concurrent index migrations correctly, and tests every LiveView with Phoenix.LiveViewTest including the cross-user case.
- name
phoenix-framework - size255 lines · ≈2,450 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds and reviews Phoenix 1.7 and 1.8 web apps - contexts as the only door to the database, scopes for per-user data access, Ecto changesets, preloads and Multi transactions, LiveView mount and handle_params lifecycle, streams and async assigns, PubSub broadcasts, LiveViewTest, and mix releases with runtime config. Use when writing Phoenix controllers, LiveViews, contexts or Ecto migrations, debugging a LiveView that double-subscribes or leaks data, or preparing a Phoenix app for production deploy. Try asking
- Build a LiveView for orders with live updates when another tab creates one
- Our LiveView subscribes twice and timers fire double. Fix the lifecycle
- Review this Phoenix app for places where users can see other users' data
Use it when
- Writing Phoenix controllers, LiveViews, contexts or Ecto migrations
- A LiveView double-subscribes, runs slow, or holds too much memory
- Adding per-user or per-tenant data access with scopes
- Preparing a Phoenix app for a release-based production deploy
Skip it when
- The work is GenServers, supervision trees or OTP design — use elixir-otp
- You need a protocol-level websocket design across stacks — use realtime-websockets
How to check the result
mix compile --warnings-as-errors and mix test pass, LiveViewTest covers each page including a forged event for another user's record, and the release boots with runtime.exs config.
The common mistake it prevents
Trusting an id from a handle_event payload and calling Repo.get! on it, which lets any connected user act on records they do not own.
Done well means: the web layer never touches Repo, every context function receives the
caller’s scope and filters by it, LiveViews subscribe only when connected and authorize
every event, large lists use streams, and mix test covers each LiveView through
Phoenix.LiveViewTest.
Workflow
- [ ] 1. Inventory versions and the generator conventions in use
- [ ] 2. Put business logic in a context function that takes a scope
- [ ] 3. Model data with Ecto schema, changeset, and a safe migration
- [ ] 4. Build the LiveView or controller on top of the context
- [ ] 5. Broadcast changes over PubSub from the context, after commit
- [ ] 6. Test the context (DataCase) and the UI (LiveViewTest)
- [ ] 7. Verify, then prepare the release
1. Inventory
mix hex.outdated # phoenix, phoenix_live_view, ecto_sql versions
mix phx.routes # router shape, live_session boundaries
grep -rn "Repo\." lib/*_web # any hit is a layering violation to fix or flag
Note: Phoenix 1.8 (Aug 2025) introduced scopes, magic-link phx.gen.auth, Tailwind v4
with daisyUI, and generates an AGENTS.md and a precommit mix alias. LiveView 1.1 added
colocated hooks (needs Phoenix 1.8); LiveView 1.2 added colocated CSS and warns on forms
with phx-change but no id. On a 1.7 app, keep 1.7 idioms unless asked to upgrade.
2. Contexts and scopes
Contexts (MyApp.Orders) are the public API of the business layer. Controllers,
LiveViews, channels, and jobs call them; nothing in lib/my_app_web calls Repo.
defmodule MyApp.Orders do
import Ecto.Query
alias MyApp.{Repo, Orders.Order}
alias MyApp.Accounts.Scope
def list_orders(%Scope{user: user}) do
Order
|> where(user_id: ^user.id)
|> order_by(desc: :inserted_at)
|> preload(:items)
|> Repo.all()
end
def get_order!(%Scope{user: user}, id) do
Repo.get_by!(Order, id: id, user_id: user.id) # 404 for other users' rows
end
def create_order(%Scope{} = scope, attrs) do
with {:ok, order} <-
%Order{user_id: scope.user.id}
|> Order.changeset(attrs)
|> Repo.insert() do
broadcast(scope, {:created, order})
{:ok, order}
end
end
def subscribe_orders(%Scope{user: user}),
do: Phoenix.PubSub.subscribe(MyApp.PubSub, "user:#{user.id}:orders")
defp broadcast(%Scope{user: user}, msg),
do: Phoenix.PubSub.broadcast(MyApp.PubSub, "user:#{user.id}:orders", msg)
end
- Ownership fields (
user_id,org_id) come from the scope, never fromattrs; keep them out ofcast/3. - On Phoenix 1.8, generators produce scoped contexts when a scope is configured
(
config :my_app, :scopes); follow the generated shape. On 1.7, passcurrent_userexplicitly with the same discipline. - Context functions return
{:ok, value}/{:error, changeset}; raise only for programmer errors andget_*!lookups.
3. Ecto
def changeset(order, attrs) do
order
|> cast(attrs, [:status, :total_cents, :currency])
|> validate_required([:status, :currency])
|> validate_number(:total_cents, greater_than_or_equal_to: 0)
|> validate_inclusion(:currency, ~w(EUR USD GBP))
|> unique_constraint([:user_id, :external_ref]) # needs the matching unique index
|> foreign_key_constraint(:user_id)
end
unique_constraint/3only converts the DB error into a changeset error; the unique index must exist in a migration.- Preload in the query (
preload: [:items]) orRepo.preload/2once per list. Calling an association insideEnum.mapper row is an N+1. - Multi-step writes go in
Ecto.MultiorRepo.transact/2(Ecto 3.13+; useRepo.transaction/1on older versions). Broadcast after the transaction returns. - Timestamps:
timestamps(type: :utc_datetime_usec)for new schemas.
Safe migrations on large tables:
defmodule MyApp.Repo.Migrations.AddStatusIndex do
use Ecto.Migration
@disable_ddl_transaction true
@disable_migration_lock true
def change do
create index(:orders, [:user_id, :status], concurrently: true)
end
end
Add NOT NULL columns as nullable, backfill in batches, then add the constraint in a
later migration. mix ecto.reset and mix ecto.drop destroy data; run them only
locally and confirm with the user before running against any shared database.
4. LiveView lifecycle
| Callback | Runs | Put here |
|---|---|---|
mount/3 | Twice: disconnected HTTP render, then connected over the socket | Cheap assigns; subscriptions and timers only if connected?(socket) |
handle_params/3 | After mount and on every push_patch / <.link patch> | URL-driven state: filters, pagination, selected item |
handle_event/3 | User events from the browser | Authorize, call the context, update assigns |
handle_info/2 | PubSub messages, send/2, async results | Apply broadcast changes |
handle_async/3 | Result of start_async/3 | Commit async results or errors |
def mount(_params, _session, socket) do
scope = socket.assigns.current_scope
if connected?(socket), do: Orders.subscribe_orders(scope)
{:ok,
socket
|> assign(:form, to_form(Orders.change_order(%Order{})))
|> stream(:orders, Orders.list_orders(scope))}
end
def handle_event("cancel", %{"id" => id}, socket) do
order = Orders.get_order!(socket.assigns.current_scope, id) # re-check ownership
{:ok, order} = Orders.cancel_order(socket.assigns.current_scope, order)
{:noreply, stream_insert(socket, :orders, order)}
end
def handle_info({:created, order}, socket),
do: {:noreply, stream_insert(socket, :orders, order, at: 0)}
Rules:
- Event payloads are client-controlled. Never trust an
idorrolefrom params without re-fetching through the scoped context. - Authenticate LiveViews with
on_mounthooks insidelive_sessionblocks in the router; aplugalone does not protect live navigation between LiveViews. - Use streams (
stream/3,stream_insert/4,stream_delete/3) for lists that grow; plain list assigns keep every row in server memory per connected user. - Slow loads use
assign_async/3orstart_async/3so mount stays fast. push_patchstays in the same LiveView (callshandle_params);push_navigatemounts a new one;redirectdoes a full page load.- Forms: build with
to_form/2, validate onphx-changewithMap.put(changeset, :action, :validate), give every form anid, and useused_input?/1to hide errors on untouched fields. - Function components (
attr,slot) by default; LiveComponents only when the piece needs its own state and events.
5. PubSub
- Broadcast from the context after a successful write, never from the LiveView, so jobs and controllers trigger the same updates.
- Include the tenant or user in the topic. A global
"orders"topic sends every tenant’s rows to every subscriber. - Broadcast minimal payloads (IDs or the already-scoped struct). Presence
(
Phoenix.Presence) is for who-is-online, not for data sync.
6. Tests
defmodule MyAppWeb.OrderLiveTest do
use MyAppWeb.ConnCase, async: true
import Phoenix.LiveViewTest
setup :register_and_log_in_user
test "creates an order", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/orders")
lv
|> form("#order-form", order: %{currency: "EUR", total_cents: 1200})
|> render_submit()
assert has_element?(lv, "#orders", "EUR")
end
test "cannot cancel someone else's order", %{conn: conn} do
other_scope = MyApp.AccountsFixtures.user_scope_fixture()
other = MyApp.OrdersFixtures.order_fixture(other_scope) # owned by another user
{:ok, lv, _} = live(conn, ~p"/orders")
assert_raise Ecto.NoResultsError, fn ->
render_click(lv, "cancel", %{"id" => other.id})
end
end
end
async: true is safe with Ecto.Adapters.SQL.Sandbox. Test contexts in DataCase
with scope fixtures, including the cross-user case for every get_* and list_*.
7. Release and deploy
mix phx.gen.release --docker # Dockerfile, release overlays, MyApp.Release.migrate
MIX_ENV=prod mix assets.deploy
MIX_ENV=prod mix release
_build/prod/rel/my_app/bin/migrate # run migrations from the release, not mix
- All environment-dependent config lives in
config/runtime.exs(DATABASE_URL,SECRET_KEY_BASE,PHX_HOST,PORT).config/prod.exsis compiled into the release. - Set
check_originto the real hosts; a wrong value silently breaks LiveView sockets. - Clustering for PubSub across nodes:
DNSCluster(generated since 1.7.11) or libcluster. Without a cluster, broadcasts reach only users on the same node. - Run migrations as a release step before switching traffic; keep migrations compatible with the previous code version for rolling deploys.
Gotchas
- Subscribing in
mount/3withoutconnected?/1subscribes the short-lived HTTP process too and doubles timers. - Large assigns are diffed and kept per socket; a 5 MB assign times 2,000 users is 10 GB.
Repo.get!/2on a raw id in a LiveView skips the scope; always go through the context.- Colocated hooks (LiveView 1.1+) need the
phoenix-colocatedimport inapp.js. - Long work inside
handle_eventblocks that user’s LiveView; usestart_async/3or a job (Oban).
Verification
mix compile --warnings-as-errors
mix format --check-formatted
mix test # or mix precommit if the project defines it
mix credo --strict # if credo is a dependency
Then open the page in two browsers as two users: actions in one appear live in the other only when they share a scope, and refreshing the page keeps URL-driven state.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.