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.

  • namephoenix-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.

~/.claude/skills/phoenix-framework/SKILL.md

--- name: phoenix-framework 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… license: MIT ---

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 from attrs; keep them out of cast/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, pass current_user explicitly with the same discipline.
  • Context functions return {:ok, value} / {:error, changeset}; raise only for programmer errors and get_*! 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/3 only converts the DB error into a changeset error; the unique index must exist in a migration.
  • Preload in the query (preload: [:items]) or Repo.preload/2 once per list. Calling an association inside Enum.map per row is an N+1.
  • Multi-step writes go in Ecto.Multi or Repo.transact/2 (Ecto 3.13+; use Repo.transaction/1 on 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

CallbackRunsPut here
mount/3Twice: disconnected HTTP render, then connected over the socketCheap assigns; subscriptions and timers only if connected?(socket)
handle_params/3After mount and on every push_patch / <.link patch>URL-driven state: filters, pagination, selected item
handle_event/3User events from the browserAuthorize, call the context, update assigns
handle_info/2PubSub messages, send/2, async resultsApply broadcast changes
handle_async/3Result of start_async/3Commit 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 id or role from params without re-fetching through the scoped context.
  • Authenticate LiveViews with on_mount hooks inside live_session blocks in the router; a plug alone 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/3 or start_async/3 so mount stays fast.
  • push_patch stays in the same LiveView (calls handle_params); push_navigate mounts a new one; redirect does a full page load.
  • Forms: build with to_form/2, validate on phx-change with Map.put(changeset, :action, :validate), give every form an id, and use used_input?/1 to 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.exs is compiled into the release.
  • Set check_origin to 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/3 without connected?/1 subscribes 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!/2 on a raw id in a LiveView skips the scope; always go through the context.
  • Colocated hooks (LiveView 1.1+) need the phoenix-colocated import in app.js.
  • Long work inside handle_event blocks that user’s LiveView; use start_async/3 or 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.

esc