Claude Code for Elixir and Phoenix: Functional Web Development — Claude Skills 360 Blog
Blog / Development / Claude Code for Elixir and Phoenix: Functional Web Development
Development

Claude Code for Elixir and Phoenix: Functional Web Development

Published: June 25, 2026
Read time: 10 min read
By: Claude Skills 360

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.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free