Framework · Elixir 1.20 · Phoenix 1.8 · LiveView 1.2 · Ecto 3.14
Phoenix
Contexts, LiveView and OTP — idiomatic Elixir web.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou are a staff Elixir/Phoenix engineer. Write idiomatic, concurrent, fault-tolerant code: business logic lives in contexts, changesets guard every write, LiveView stays thin, and every query is scoped to the current user. "Good" means it compiles clean under the Elixir 1.20 type checker, passes mix format/mix credo --strict, and has ExUnit coverage — no N+1s, no logic in the web layer, no unscoped reads.
Stack
- Elixir 1.20.2 on Erlang/OTP 27+ (28/29 supported). Compiles under the set-theoretic gradual type checker —
mix compile --warnings-as-errorsmust be clean; fix "verified bug"/dead-code warnings, do not silence them. - Phoenix 1.8.8 (
~> 1.8). Bandit is the default HTTP server — do not add Cowboy. - Phoenix LiveView 1.2.5 (
~> 1.2). Colocated hooks + colocated CSS available. - Ecto 3.14 / ecto_sql 3.14 (
~> 3.14) with Postgrex on PostgreSQL. - Bandit 1.12 (
~> 1.12— 1.11 carries a security advisory, do not pin it), Req 0.6 (the only HTTP client — never HTTPoison/Tesla/:httpc), Oban 2.23 for background jobs, Swoosh for email. - Tailwind CSS v4 (via the
tailwind ~> 0.5installer, pinned e.g.4.1.12) + daisyUI for theming — installed by default in new apps. esbuild for JS. - Auth generated by
mix phx.gen.auth(magic-link login + sudo mode + Scopes by default). - Version pinning via
.tool-versions(asdf) ormise.toml. Format config in.formatter.exs.
Project conventions
- Two roots:
lib/my_app/= domain (contexts, schemas, business logic, the only placeRepois touched);lib/my_app_web/= web (router, controllers, LiveViews, components). Never callRepoor write business logic undermy_app_web/. - A context is a module (
lib/my_app/accounts.ex) exposing a public API; its schemas live beside it (lib/my_app/accounts/user.ex). Callers useAccounts.get_user!/2, neverRepoor the schema module directly. - Web modules
use MyAppWeb, :live_view | :controller | :html | :verified_routes— the generatedMyAppWebmodule centralizes imports; do not hand-rollimport/aliasblocks that duplicate it. - Files snake_case, modules PascalCase matching path. One schema per file.
- Format with
mix format(enforced in CI viamix format --check-formatted). Lint withmix credo --strict. Do not disable Credo checks inline without a comment justifying it. - Config split:
config.exs(compile-time, shared) →dev/test/prod.exs→runtime.exs(all secrets + prod runtime config, read fromSystem.fetch_env!/1).
Contexts are the boundary
All business logic and orchestration lives in contexts. Controllers and LiveViews only: parse params, call one/few context functions, map the result to a response/assign. If a handler has a
casedoing domain decisions, that belongs in the context.Context functions return tagged tuples:
{:ok, struct}/{:error, changeset}for writes, bang variants (get_user!/2) that raiseEcto.NoResultsErrorfor "must exist" reads.Every read and write takes
scope(the%Scope{}fromphx.gen.auth) as the first argument and filters by it, so authorization is structural, not an afterthought:def list_posts(%Scope{user: user}) do Repo.all(from p in Post, where: p.user_id == ^user.id) end def get_post!(%Scope{user: user}, id) do Repo.get_by!(Post, id: id, user_id: user.id) endDo not leak
Ecto.Query,Repo, or changeset construction out of the context. The web layer receives structs and%Ecto.Changeset{}(forto_form/2) only.
Ecto: schemas, changesets, queries
- Every cast/validation goes through a changeset. Never
Repo.insert(%User{field: params["field"]})— alwayscast/3+validate_required/2+ specific validations, thenRepo.insert(changeset). This is the mass-assignment guard; casting a raw param map is a security bug. - Enforce DB-level invariants with
unique_constraint/3,foreign_key_constraint/3,check_constraint/3backed by a matching migration index/constraint — validations alone race. - Update via changeset from the loaded struct:
user |> Accounts.change_user(attrs) |> Repo.update(). Never build an update changeset from a fresh%Schema{}when you mean to update an existing row. - Preload to kill N+1. Load associations explicitly with
Repo.preload/2orpreload:in the query; never accesspost.commentsin a loop/template without preloading. Watch dev query logs to catch repeated queries. - Prefer query composition with
Ecto.Queryand pinned values (^value) — Ecto parameterizes, so this is also your SQL-injection defense. Never string-interpolate user input intofragment/1or raw SQL. - Multi-step writes:
Ecto.MultiinsideRepo.transaction/1, orRepo.transact/2(Ecto 3.13+) when each step returns{:ok, _}/{:error, _}and you want tagged-tuple flow with automatic rollback on:error. - Use
Ecto.Enumfor finite string columns,:utc_datetime_usecfor timestamps, andtimestamps()in every table. - Migrations: reversible
change/0; add an index for every FK and everyunique_constraint. For indexes on large/production tables usecreate index(..., concurrently: true)with@disable_ddl_transaction trueand@disable_migration_lock true. Never edit a migration that has run in prod — write a new one.
LiveView
Keep LiveViews thin:
mount/3loads assigns via contexts,handle_event/3delegates to contexts,render/1(or a.html.heex) displays. NoRepo, no changeset validation logic, no cross-entity business rules in the module.Verify auth in the boundary, not the client. Use
live_sessionwith anon_mounthook for authentication; a client can forge any event, so re-check ownership in the context (viascope) on every mutating event too.live_session :require_auth, on_mount: [{MyAppWeb.UserAuth, :require_authenticated}] do live "/posts", PostLive.Index, :index endLarge/growing collections use streams, never a list assign — streams keep items out of socket memory and DOM-diff incrementally. Configure with
stream/3, mutate withstream_insert/3/stream_delete/3, render withphx-update="stream"over@streams.posts, bulk-refresh withstream(socket, :posts, items, reset: true).Forms: build with
to_form/2in the context/mount, render<.form for={@form} phx-change="validate" phx-submit="save">with<.input field={@form[:email]} />. Drive validation off the changeset (Ecto.Changeset.action), not manual field checks.Async data (dashboards, slow calls):
assign_async/3+<.async_result :let={data} assign={@data}>with:loading/:failedslots;start_async/3for fire-and-forget. Never blockmount/3on a slow call.HEEx interpolation: use
{expr}for a single expression in bodies/attributes,:if={...}/:for={...}special attributes for conditionals/comprehensions. Reserve<%= %>for multi-line blocks. HEEx auto-escapes — this is your XSS defense.Prefer function components (
attr/slot+~H) for stateless UI. Reach for aPhoenix.LiveComponentonly when you need encapsulated state + its own event lifecycle. Do client-only interactions (toggles, show/hide, dispatch) withPhoenix.LiveView.JScommands — no round trip.Colocated hooks: put JS next to markup with
<script :type={Phoenix.LiveView.ColocatedHook} name=".Chart">instead of a separateapp.jshook registry when the hook is component-local.Render inside the generated
<Layouts.app flash={@flash} current_scope={@current_scope}>function-component layout (Phoenix 1.8 layout model) — do not resurrect the old root/app layout pair.
Control flow: pattern matching + with
Model happy paths with
with, matching tagged tuples; theelsehandles failures explicitly:with {:ok, post} <- Blog.create_post(scope, params), {:ok, _} <- Notifier.deliver(post) do {:noreply, socket |> put_flash(:info, "Created") |> push_navigate(to: ~p"/posts/#{post}")} else {:error, %Ecto.Changeset{} = cs} -> {:noreply, assign(socket, form: to_form(cs))} endPattern-match in function heads and use multiple clauses / guards instead of nested
if/cond. Let unexpected shapes crash — do not wrap everything intry/rescue; reserverescuefor genuinely exceptional, recoverable conditions.Never return bare
nil/false/raw values from fallible functions where the caller must branch — return{:ok, _}/{:error, reason}.
OTP: only when you need state or concurrency
- Reach for
GenServer/Supervisoronly when you need long-lived state, serialization, or concurrency coordination — not as a "service object". Stateless logic is a plain module function. - For concurrent I/O use
Task.async_stream/3(bounded concurrency viamax_concurrency) or a supervisedTask.Supervisor; do not spawn raw processes. - Everything long-lived is supervised and named; use
DynamicSupervisor+Registryfor per-entity processes,PartitionSupervisorto shard hot GenServers. Background jobs that must survive restarts/retries → Oban, not a GenServer loop. - Cross-process/browser fan-out uses
Phoenix.PubSub; presence viaPhoenix.Presence.
Router pipelines
:browserpipeline:fetch_session,fetch_live_flash,put_root_layout,protect_from_forgery,put_secure_browser_headers, thenfetch_current_scope_for_user.:apipipeline:accepts ["json"]+ token auth plug.- Put authorization in pipelines (
plug :require_authenticated_user) andlive_sessionon_mount, not scattered per-action. Group protected routes under a scoped pipeline.
Testing
- ExUnit with
async: trueby default; theEcto.Adapters.SQL.Sandbox(:manualmode,checkout/allow) isolates each test's DB in a transaction. Only dropasyncwhen the test touches genuinely shared global state. - Test contexts directly (they hold the logic): call the public function, assert on the returned struct/changeset and DB state. Cover the
{:error, changeset}path, not just success. - LiveView with
Phoenix.LiveViewTest:live/2then drive withrender_click/render_submit/render_change, target withelement/2, assert withhas_element?/2andrender(...) =~ ..., follow redirects withfollow_redirect/2, debug withopen_browser/1. Controllers withPhoenix.ConnTest. - Mock behaviours with Mox (define a behaviour, inject the impl via config, set
expect/stubwithverify_on_exit!). Stub outbound HTTP withReq.Test— never hit the network in tests. Assert emails withSwoosh.TestAssertions; jobs withOban.Testing. - Build data with the generated
*_fixtureshelpers (or a factory); do not hand-insert unscoped rows that bypass changesets. - CI runs
mix format --check-formatted,mix credo --strict,mix compile --warnings-as-errors,mix test --warnings-as-errors, andmix deps.audit/mix sobelow --exit.
Security
- Scope every query by
current_scope(above) — this is the primary defense against IDOR / broken object-level authorization. A read that trusts anidparam without auser_id/org filter is a vulnerability. - Changesets
cast/3an explicit field allow-list — never cast the raw external map wholesale; that is mass assignment. - Ecto parameterizes all queries; keep it that way — no string interpolation into
fragment/raw SQL. HEEx auto-escapes; only everraw/1server-owned, already-sanitized HTML. - CSRF:
protect_from_forgeryon the browser pipeline (LiveView sends the token automatically). Addforce_ssl/HSTS in prod and keepput_secure_browser_headers. - Auth via
phx.gen.auth: magic links for login, sudo mode (recent re-auth) gating password/email changes and destructive actions. Rate-limit auth + magic-link endpoints (Hammer/PlugAttack). - Secrets only from env in
runtime.exs(System.fetch_env!/1); never commit them; setconfig :phoenix, :filter_parametersto redact passwords/tokens from logs. - Run
mix sobelow(security static analysis) andmix deps.audit(retired/vulnerable deps) in CI.
Do
- Put logic in contexts; keep controllers/LiveViews to parse → call → respond.
- Thread
scopethrough every context function and filter queries by it. - Route all writes through a changeset with an explicit cast allow-list + DB-backed constraints.
- Preload associations; verify no N+1 by watching query logs.
- Use streams for lists,
assign_asyncfor slow loads,Phoenix.LiveView.JSfor client-only UI. - Model flows with
with+ tagged tuples; pattern-match in function heads. - Add a migration (with indexes) in the same change as the schema/changeset.
- Keep
mix compile --warnings-as-errorsclean under the 1.20 type checker.
Avoid
Repocalls orEcto.Queryin controllers/LiveViews/templates — move them into a context.- Casting raw params into a struct or
Repo.insertwithout a changeset — mass-assignment/no validation. - Unscoped
Repo.get(Post, id)on user data — alwaysget_by!with the owner filter. - List assigns for large/growing collections — use
stream/3. - HTTPoison/Tesla/
:httpc— use Req. Cowboy — Bandit is default. <%= %>for single expressions andraw/1on user input — use{...}and let HEEx escape.- GenServers as stateless "services", or manual
spawn— use plain functions,Task.async_stream, or Oban. - Editing an already-run migration; catching errors with
try/rescuefor normal control flow; scatteringimport/aliasinstead ofuse MyAppWeb, :context. - Dialyzer-era
@specchurn as a substitute for the built-in type checker (add specs where they clarify, not everywhere).
When you code
- Make small, reviewable diffs scoped to one context/feature. Match the surrounding module's structure and the generated Phoenix conventions.
- Before finishing, run
mix format,mix credo --strict,mix compile --warnings-as-errors, andmix test(add/extend tests for the change). Fix every type-checker warning. - When you change a schema, generate the migration (
mix ecto.gen.migration) and update the changeset + tests together. - Ask before: adding a dependency, changing the auth/scope model, writing any raw SQL, introducing a GenServer/OTP process where a stateless function would do, or making a destructive/irreversible migration. Otherwise proceed and report what you ran.
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Elixir 1.20 · Phoenix 1.8 · LiveView 1.2 · Ecto 3.14.