[TERMINAL · SKILLS]
> mounting /skills...
> indexing skill manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Skills/Phoenix
>

Phoenix

Phoenix is an Elixir web framework built on the BEAM VM for fault-tolerant, real-time applications. It features LiveView for interactive UIs without JavaScript, channels for WebSockets, and Ecto for database access with compile-time query validation.

#elixir#web#realtime#liveview#websocket
terminal-skillsv1.1.0
Works with:claude-codeopenai-codexgemini-clicursor
Source
Trust Score
100/ 100
3.09×
Impact

Validation

Score is stale (SKILL.md changed since last review)
Quality
100/ 100
Does it follow best practices?
6 PASS
Security
Passed
No known issues
Content review + injection scan
Impact
3.09×
22% → 68% agent success
Avg across 3 eval scenarios
Scored 10/24/2026 · skill v1.1.0
$
✓ Installed phoenix v1.1.0

Getting Started

  1. Install the skill using the command above
  2. Open your AI coding agent (Claude Code, Codex, Gemini CLI, or Cursor)
  3. Reference the skill in your prompt
  4. The AI will use the skill's capabilities automatically

Example Prompts

  • "Review the open pull requests and summarize what needs attention"
  • "Generate a changelog from the last 20 commits on the main branch"

Documentation

Overview

Phoenix leverages the Erlang VM (BEAM) for massive concurrency and fault tolerance. LiveView enables rich, real-time UIs with server-rendered HTML, with little custom JavaScript. This skill targets Phoenix 1.8 (1.8.16 checked) with LiveView 1.x and Ecto 3.

Instructions

Installation

bash
mix archive.install hex phx_new      # asks to confirm; add --force to skip the prompt
mix phx.new blog --database postgres # PostgreSQL is the default
cd blog
mix setup                            # deps.get, ecto.setup, assets.setup, assets.build
mix phx.server                       # http://localhost:4000 (dev server binds to localhost)

On Linux install inotify-tools for live reload. The examples below use the app name my_app; substitute your own.

Ecto Schema and Migration

elixir
# lib/my_app/articles/article.ex — Ecto schema
defmodule MyApp.Articles.Article do
  use Ecto.Schema
  import Ecto.Changeset

  schema "articles" do
    field :title, :string
    field :slug, :string
    field :body, :string
    field :published, :boolean, default: false
    belongs_to :author, MyApp.Accounts.User
    timestamps()
  end

  def changeset(article, attrs) do
    article
    |> cast(attrs, [:title, :body, :published, :author_id])
    |> validate_required([:title, :body, :author_id])
    |> validate_length(:title, max: 200)
    |> unique_constraint(:slug)
    |> generate_slug()
  end

  defp generate_slug(changeset) do
    case get_change(changeset, :title) do
      nil -> changeset
      title -> put_change(changeset, :slug, slugify(title))
    end
  end

  defp slugify(title) do
    title |> String.downcase() |> String.replace(~r/[^a-z0-9]+/u, "-") |> String.trim("-")
  end
end
elixir
# priv/repo/migrations/20261024120000_create_articles.exs — migration
defmodule MyApp.Repo.Migrations.CreateArticles do
  use Ecto.Migration

  def change do
    create table(:articles) do
      add :title, :string, null: false, size: 200
      add :slug, :string, null: false
      add :body, :text, null: false
      add :published, :boolean, default: false
      add :author_id, references(:users, on_delete: :delete_all), null: false
      timestamps()
    end

    create unique_index(:articles, [:slug])
  end
end

Context Module

elixir
# lib/my_app/articles.ex
defmodule MyApp.Articles do
  import Ecto.Query
  alias MyApp.Repo
  alias MyApp.Articles.Article

  def list_published do
    Article
    |> where(published: true)
    |> order_by(desc: :inserted_at)
    |> preload(:author)
    |> Repo.all()
  end

  def get_published_by_slug!(slug) do
    Article |> where(slug: ^slug, published: true) |> preload(:author) |> Repo.one!()
  end

  def search(term) do
    Article
    |> where([a], a.published and ilike(a.title, ^"%#{term}%"))
    |> order_by(desc: :inserted_at)
    |> preload(:author)
    |> Repo.all()
  end

  def create_article(attrs) do
    %Article{}
    |> Article.changeset(attrs)
    |> Repo.insert()
  end
end

Router

elixir
# lib/my_app_web/router.ex — routing
defmodule MyAppWeb.Router do
  use MyAppWeb, :router

  # pipeline :browser (session, flash, root layout, CSRF) is generated by mix phx.new
  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", MyAppWeb do
    pipe_through :browser
    live "/articles", ArticleLive.Index, :index
    live "/articles/:slug", ArticleLive.Show, :show   # Show: see mix phx.gen.live
  end

  scope "/api", MyAppWeb.API do
    pipe_through :api
    resources "/articles", ArticleController, only: [:index, :create]
  end
end

LiveView

elixir
# lib/my_app_web/live/article_live/index.ex
defmodule MyAppWeb.ArticleLive.Index do
  use MyAppWeb, :live_view
  alias MyApp.Articles
  @impl true
  def mount(_params, _session, socket) do
    {:ok, assign(socket, articles: Articles.list_published(), search: "")}
  end

  @impl true
  def handle_event("search", %{"query" => query}, socket) do
    {:noreply, assign(socket, articles: Articles.search(query), search: query)}
  end
end
heex
<div>
  <h1>Articles</h1>
  <form phx-change="search">
    <input type="text" name="query" value={@search} phx-debounce="300" placeholder="Search..." />
  </form>
  <div :for={article <- @articles}>
    <h2><.link navigate={~p"/articles/#{article.slug}"}>{article.title}</.link></h2>
    <p>By {article.author.email}</p>
  </div>
</div>

JSON Controller

elixir
# lib/my_app_web/controllers/api/article_controller.ex — API controller
defmodule MyAppWeb.API.ArticleController do
  use MyAppWeb, :controller
  alias MyApp.Articles

  def index(conn, _params) do
    json(conn, %{data: Enum.map(Articles.list_published(), &article_json/1)})
  end

  def create(conn, %{"article" => params}) do
    case Articles.create_article(params) do
      {:ok, article} -> conn |> put_status(:created) |> json(%{data: article_json(article)})
      {:error, changeset} -> conn |> put_status(422) |> json(%{errors: format_errors(changeset)})
    end
  end

  defp article_json(a), do: %{id: a.id, title: a.title, slug: a.slug}

  defp format_errors(changeset) do
    Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
  end
end

Channels (Real-time)

elixir
# lib/my_app_web/channels/user_socket.ex — authenticate before any channel joins
defmodule MyAppWeb.UserSocket do
  use Phoenix.Socket
  channel "room:*", MyAppWeb.RoomChannel

  @impl true
  def connect(%{"token" => token}, socket, _connect_info) do
    case Phoenix.Token.verify(MyAppWeb.Endpoint, "user socket", token, max_age: 86_400) do
      {:ok, user_id} -> {:ok, assign(socket, :user_id, user_id)}
      {:error, _} -> :error
    end
  end

  def connect(_params, _socket, _connect_info), do: :error

  @impl true
  def id(socket), do: "user_socket:#{socket.assigns.user_id}"
end

# lib/my_app_web/channels/room_channel.ex
defmodule MyAppWeb.RoomChannel do
  use MyAppWeb, :channel

  def join("room:" <> _room_id, _params, socket), do: {:ok, socket}

  def handle_in("message", %{"body" => body}, socket) do
    broadcast!(socket, "message", %{body: body, user: socket.assigns.user_id})
    {:noreply, socket}
  end
end

Register the socket in endpoint.ex: socket "/socket", MyAppWeb.UserSocket, websocket: true, longpoll: false.

Testing

elixir
defmodule MyApp.ArticlesTest do
  use MyApp.DataCase
  import MyApp.AccountsFixtures   # created by `mix phx.gen.auth Accounts User users`

  test "create_article/1 builds a slug" do
    attrs = %{title: "Launch notes", body: "Content", author_id: user_fixture().id}
    assert {:ok, article} = MyApp.Articles.create_article(attrs)
    assert article.slug == "launch-notes"
  end
end

Key Commands

mix phx.gen.live Articles Article articles title:string body:text generates a LiveView CRUD with migration and tests; mix ecto.migrate, mix phx.routes, mix test, iex -S mix phx.server (dev server with REPL), mix phx.gen.secret (value for SECRET_KEY_BASE).

Examples

Example 1: "Add a blog section with search"

Run mix phx.gen.auth Accounts User users for authors, add the schema, migration and context above, run mix ecto.migrate, and add the live routes. Typing in the search box sends a search event (debounced 300 ms) and the list re-renders without a page load.

Example 2: "Give me a JSON endpoint for mobile"

Add the /api scope and ArticleController. curl -s -X POST localhost:4000/api/articles -H 'content-type: application/json' -d '{"article":{"title":"Launch notes","body":"Hello","author_id":1}}' returns 201 with {"data":{"id":1,"title":"Launch notes","slug":"launch-notes"}}; a missing title returns 422 with {"errors":{"title":["can't be blank"]}}.

Guidelines

  • Production config is read from config/runtime.exs: set SECRET_KEY_BASE, DATABASE_URL, PHX_HOST and PHX_SERVER=true as environment variables; build with MIX_ENV=prod mix release.
  • Authenticate sockets and check authorization in every handle_event / handle_in; clients can send any event name and payload.
  • Pass only whitelisted fields to cast/3 (never cast(attrs, Map.keys(attrs))) and never cast privileged fields such as is_admin.
  • Keep the :browser pipeline's CSRF protection; skip it only in :api pipelines that authenticate with tokens.
  • Use phx-debounce on inputs that fire on every keystroke, and stream/3 instead of assigning large lists to the socket.

Key Patterns

  • Business logic lives in context modules; controllers and LiveViews only delegate to them.
  • Validate through changesets; Ecto never lazy-loads, so preload explicitly.
  • Use Ecto.Multi for multi-step transactions and Phoenix.PubSub.broadcast to push updates across LiveView processes.