Elixir and Phoenix solve a specific class of problems exceptionally well: high-concurrency systems, real-time features, and fault-tolerant services. Phoenix LiveView lets you build real-time interactive UIs without writing JavaScript, while the BEAM (Erlang VM) handles millions of lightweight processes. Claude Code understands Elixir’s functional patterns, OTP principles, and Phoenix conventions.
This guide covers Elixir/Phoenix with Claude Code: LiveView, Ecto queries, GenServer, and supervision trees.
Phoenix Project Setup
CLAUDE.md for Phoenix Projects
## Phoenix Application
- Elixir 1.16+, Phoenix 1.7+, LiveView 0.20+
- Database: PostgreSQL via Ecto
- Auth: phx.gen.auth (built-in sessions, not JWT)
- Testing: ExUnit, Wallaby for E2E, Mox for mocks
## Project conventions
- Contexts: lib/myapp/{context}.ex — domain boundary functions
- Schemas: lib/myapp/{context}/{schema}.ex — Ecto schemas
- LiveViews: lib/myapp_web/live/{feature}_live.ex
- Components: lib/myapp_web/components/ (CoreComponents + custom)
- Queries in contexts, not in LiveViews or controllers
- Use Ecto.Multi for multi-step database transactions
## Elixir conventions
- Pattern matching over conditionals where possible
- Pipeline operator |> for data transformations
- with statement for multi-step operations that may fail
- Functions in contexts return {:ok, result} or {:error, reason}
- No business logic in LiveView mounts — call context functions
## Key commands
- Start: `mix phx.server`
- Tests: `mix test`
- Migrations: `mix ecto.migrate`
- Interactive: `iex -S mix`
Ecto Schemas and Contexts
Create a Posts context for a blog.
Needs create/update/delete/list with filtering.
# lib/myblog/blog/post.ex
defmodule Myblog.Blog.Post do
use Ecto.Schema
import Ecto.Changeset
schema "posts" do
field :title, :string
field :slug, :string
field :content, :string
field :excerpt, :string
field :status, Ecto.Enum, values: [:draft, :published, :archived], default: :draft
field :published_at, :utc_datetime
belongs_to :author, Myblog.Accounts.User
many_to_many :tags, Myblog.Blog.Tag, join_through: "posts_tags"
timestamps(type: :utc_datetime)
end
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :content, :excerpt, :status, :published_at, :author_id])
|> validate_required([:title, :content, :author_id])
|> validate_length(:title, max: 255)
|> validate_length(:excerpt, max: 500)
|> validate_published_requirements()
|> put_slug()
|> unique_constraint(:slug)
end
defp validate_published_requirements(changeset) do
if get_field(changeset, :status) == :published do
changeset
|> validate_required([:content])
|> put_change(:published_at, DateTime.utc_now() |> DateTime.truncate(:second))
else
changeset
end
end
defp put_slug(changeset) do
case get_change(changeset, :title) do
nil -> changeset
title -> put_change(changeset, :slug, Slug.slugify(title))
end
end
end
# lib/myblog/blog.ex — Context module (public API for blog domain)
defmodule Myblog.Blog do
import Ecto.Query, warn: false
alias Myblog.Repo
alias Myblog.Blog.Post
def list_posts(opts \\ []) do
status = Keyword.get(opts, :status, :published)
author_id = Keyword.get(opts, :author_id)
page = Keyword.get(opts, :page, 1)
per_page = Keyword.get(opts, :per_page, 20)
Post
|> where([p], p.status == ^status)
|> then(fn query ->
if author_id, do: where(query, [p], p.author_id == ^author_id), else: query
end)
|> order_by([p], desc: p.published_at, desc: p.inserted_at)
|> preload([:author, :tags])
|> Repo.paginate(page: page, page_size: per_page)
end
def get_post!(id), do: Repo.get!(Post, id) |> Repo.preload([:author, :tags])
def get_post_by_slug!(slug) do
Post
|> where([p], p.slug == ^slug and p.status == :published)
|> preload([:author, :tags])
|> Repo.one!()
end
def create_post(attrs, author) do
%Post{author_id: author.id}
|> Post.changeset(attrs)
|> Repo.insert()
end
def update_post(%Post{} = post, attrs) do
post
|> Post.changeset(attrs)
|> Repo.update()
end
def publish_post(%Post{} = post) do
update_post(post, %{status: :published})
end
def delete_post(%Post{} = post) do
Repo.delete(post)
end
end
Phoenix LiveView
Build a real-time task board where multiple users can
see task status updates without page refresh.
# lib/myblog_web/live/task_board_live.ex
defmodule MyblogWeb.TaskBoardLive do
use MyblogWeb, :live_view
alias Myblog.Tasks
@topic "task_board"
def mount(_params, _session, socket) do
# Subscribe to task updates when connected (not on first render)
if connected?(socket) do
Phoenix.PubSub.subscribe(Myblog.PubSub, @topic)
end
tasks = Tasks.list_tasks()
{:ok, assign(socket, tasks: tasks, adding_task: false)}
end
def handle_event("toggle_task", %{"id" => id}, socket) do
task = Tasks.get_task!(id)
{:ok, updated_task} = Tasks.toggle_task(task)
# Broadcast to all connected clients
Phoenix.PubSub.broadcast(
Myblog.PubSub,
@topic,
{:task_updated, updated_task}
)
{:noreply, update(socket, :tasks, fn tasks ->
Enum.map(tasks, fn t -> if t.id == updated_task.id, do: updated_task, else: t end)
end)}
end
def handle_event("add_task", %{"task" => task_params}, socket) do
case Tasks.create_task(task_params, socket.assigns.current_user) do
{:ok, task} ->
Phoenix.PubSub.broadcast(Myblog.PubSub, @topic, {:task_added, task})
{:noreply, assign(socket, adding_task: false)}
{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
# Handle PubSub messages from other clients
def handle_info({:task_updated, task}, socket) do
{:noreply, update(socket, :tasks, fn tasks ->
Enum.map(tasks, fn t -> if t.id == task.id, do: task, else: t end)
end)}
end
def handle_info({:task_added, task}, socket) do
{:noreply, update(socket, :tasks, fn tasks -> [task | tasks] end)}
end
def render(assigns) do
~H"""
<div class="task-board">
<h1>Task Board</h1>
<ul>
<li :for={task <- @tasks} class={if task.completed, do: "completed"}>
<label>
<input
type="checkbox"
checked={task.completed}
phx-click="toggle_task"
phx-value-id={task.id}
/>
<%= task.title %>
</label>
<span class="assignee"><%= task.assignee.name %></span>
</li>
</ul>
<button phx-click={JS.toggle(to: "#add-task-form")}>
+ Add Task
</button>
<form id="add-task-form" phx-submit="add_task" hidden>
<input type="text" name="task[title]" placeholder="Task title" required />
<button type="submit">Add</button>
</form>
</div>
"""
end
end
GenServer for Background Work
We need a rate limiter that tracks API calls per user
across all server instances using ETS (in-memory storage).
# lib/myblog/rate_limiter.ex
defmodule Myblog.RateLimiter do
use GenServer
@table :rate_limit_table
@window_ms 60_000 # 1 minute window
@max_requests 100 # 100 requests per window
def start_link(_opts) do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def init(_) do
:ets.new(@table, [:named_table, :public, :set, read_concurrency: true])
# Periodically clean up expired entries
:timer.send_interval(30_000, self(), :cleanup)
{:ok, %{}}
end
# Public API — called directly (reads from ETS without going through GenServer)
def check_rate_limit(user_id) do
now = System.monotonic_time(:millisecond)
window_start = now - @window_ms
case :ets.lookup(@table, user_id) do
[{^user_id, requests}] ->
# Filter to requests within current window
recent = Enum.filter(requests, fn ts -> ts > window_start end)
if length(recent) >= @max_requests do
{:error, :rate_limited}
else
:ets.insert(@table, {user_id, [now | recent]})
{:ok, @max_requests - length(recent) - 1}
end
[] ->
:ets.insert(@table, {user_id, [now]})
{:ok, @max_requests - 1}
end
end
# GenServer callback — cleanup expired entries
def handle_info(:cleanup, state) do
now = System.monotonic_time(:millisecond)
window_start = now - @window_ms
:ets.foldl(fn {key, requests}, _acc ->
recent = Enum.filter(requests, &(&1 > window_start))
if recent == [], do: :ets.delete(@table, key), else: :ets.insert(@table, {key, recent})
end, nil, @table)
{:noreply, state}
end
end
# lib/myblog/application.ex — add to supervision tree
def start(_type, _args) do
children = [
Myblog.Repo,
MyAppWeb.Endpoint,
{Phoenix.PubSub, name: Myblog.PubSub},
Myblog.RateLimiter, # Add to supervision tree — auto-restarts on crash
]
opts = [strategy: :one_for_one, name: Myblog.Supervisor]
Supervisor.start_link(children, opts)
end
The supervisor restarts Myblog.RateLimiter automatically if it crashes. The BEAM’s fault tolerance model means errors in one process don’t affect others.
For GraphQL APIs in Elixir, Absinthe integrates naturally with Phoenix and Ecto. For the testing patterns for LiveView, ExUnit with Phoenix.LiveViewTest covers mount/event testing without a browser. For deploying Phoenix to production with releases, Claude Code generates the mix release configuration and Docker setup — see the Docker guide for container patterns. The Claude Skills 360 bundle includes Elixir/Phoenix skill sets for real-time applications. Start with the free tier to generate LiveView components and Ecto queries.