skills/elixir/elixir-part3-phoenix.md

17 KiB

Elixir Part 3: Phoenix Framework — v1.8.5 (Current)

Phoenix is the web framework for Elixir. Version 1.8.5 is current. It provides MVC controllers, real-time LiveView, Channels for WebSocket communication, and comprehensive tooling for authentication, testing, and deployment.

What's New in Phoenix 1.8

  • Scopes in generators — secure data access by default (e.g., current_user automatically applied)
  • Magic links (passwordless auth) and "sudo mode" in mix phx.gen.auth
  • daisyUI integration — light/dark/system mode support out of the box
  • Simplified layouts — single root.html.heex wraps everything; dynamic layouts are function components
  • use Phoenix.Controller now requires :formats — must specify formats: [:html] or formats: [:html, :json]
  • Updated security headerscontent-security-policy with base-uri 'self'; frame-ancestors 'self'; dropped deprecated x-frame-options and x-download-options
  • config variable removed from Phoenix.Endpoint — use Application.compile_env/3 instead
  • Deprecated: :namespace, :put_default_views, layouts without modules, :trailing_slash in router

Project Setup

# New project (Phoenix Express — quickest path)
curl https://new.phoenixframework.org/my_app | sh

# Traditional setup
mix phx.new my_app
mix phx.new my_app --no-ecto    # Without database
mix phx.new my_app --no-html    # API only
mix phx.new my_app --database sqlite3  # SQLite instead of Postgres

Directory Structure

my_app/
├── lib/
│   ├── my_app/              # Business logic (contexts, schemas)
│   │   ├── application.ex   # Supervision tree
│   │   ├── repo.ex          # Ecto Repo
│   │   └── catalog/         # Context: Catalog
│   │       ├── product.ex   # Schema
│   │       └── catalog.ex   # Context module
│   └── my_app_web/          # Web layer
│       ├── endpoint.ex      # HTTP entry point
│       ├── router.ex        # Routes + pipelines
│       ├── components/
│       │   ├── core_components.ex  # Shared UI components
│       │   └── layouts.ex          # Layout components
│       ├── controllers/
│       │   ├── page_controller.ex
│       │   └── page_html/          # Templates for controller
│       │       └── home.html.heex
│       └── live/             # LiveView modules
│           └── counter_live.ex
├── config/
│   ├── config.exs            # Compile-time config
│   ├── dev.exs / prod.exs
│   └── runtime.exs           # Runtime config (env vars, secrets)
├── priv/
│   ├── repo/migrations/
│   └── static/               # Static assets
├── test/
│   ├── support/
│   │   ├── conn_case.ex      # Test helpers for controllers
│   │   └── data_case.ex      # Test helpers for data layer
│   └── my_app_web/
└── mix.exs

Router & Pipelines

defmodule MyAppWeb.Router do
  use MyAppWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", MyAppWeb do
    pipe_through :browser

    get "/", PageController, :home
    live "/counter", CounterLive         # LiveView route
    resources "/products", ProductController  # RESTful CRUD
  end

  scope "/api", MyAppWeb do
    pipe_through :api

    resources "/items", ItemController, except: [:new, :edit]
  end
end

Verified Routes — ~p Sigil

Always use ~p instead of string paths. Compile-time verified against your router.

# In templates (HEEx)
~H"""
<a href={~p"/products/#{product}"}>View</a>
<a href={~p"/products/#{product}/edit"}>Edit</a>
"""

# In controllers
redirect(conn, to: ~p"/products/#{product}")

# With query params
~p"/products?page=#{page}&sort=name"
~p"/products?#{%{page: 1, sort: "name"}}"

# URL generation (includes host)
url(~p"/products/#{product}")
# => "https://example.com/products/42"

Controllers (1.8 Style)

defmodule MyAppWeb.ProductController do
  use MyAppWeb, :controller

  # REQUIRED in 1.8: specify formats
  # This is typically set in MyAppWeb :controller function

  def index(conn, _params) do
    products = Catalog.list_products()
    render(conn, :index, products: products)
  end

  def show(conn, %{"id" => id}) do
    product = Catalog.get_product!(id)
    render(conn, :show, product: product)
  end

  def create(conn, %{"product" => product_params}) do
    case Catalog.create_product(product_params) do
      {:ok, product} ->
        conn
        |> put_flash(:info, "Product created.")
        |> redirect(to: ~p"/products/#{product}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :new, changeset: changeset)
    end
  end
end

View module naming: For ProductController, Phoenix looks for ProductHTML (for :html format) and ProductJSON (for :json format).

# lib/my_app_web/controllers/product_html.ex
defmodule MyAppWeb.ProductHTML do
  use MyAppWeb, :html
  embed_templates "product_html/*"  # Loads .heex files from directory
end

# lib/my_app_web/controllers/product_json.ex
defmodule MyAppWeb.ProductJSON do
  def index(%{products: products}), do: %{data: for(p <- products, do: data(p))}
  def show(%{product: product}), do: %{data: data(product)}
  defp data(product), do: %{id: product.id, title: product.title, price: product.price}
end

Components and HEEx

defmodule MyAppWeb.CoreComponents do
  use Phoenix.Component

  # Declare attributes with types and docs
  attr :type, :string, default: "button"
  attr :class, :string, default: nil
  attr :rest, :global  # Passes through all other HTML attrs
  slot :inner_block, required: true

  def button(assigns) do
    ~H"""
    <button type={@type} class={["btn", @class]} {@rest}>
      {render_slot(@inner_block)}
    </button>
    """
  end

  # Table component with slots
  attr :rows, :list, required: true
  slot :col, required: true do
    attr :label, :string
  end

  def table(assigns) do
    ~H"""
    <table>
      <thead>
        <tr>
          <th :for={col <- @col}>{col[:label]}</th>
        </tr>
      </thead>
      <tbody>
        <tr :for={row <- @rows}>
          <td :for={col <- @col}>{render_slot(col, row)}</td>
        </tr>
      </tbody>
    </table>
    """
  end
end

HEEx syntax notes:

  • {@var} — render assign (curly braces, not <%= %>)
  • :if={condition} — conditional rendering on any tag
  • :for={item <- list} — iteration on any tag
  • <.component_name /> — call function component with dot notation
  • <Component.name /> — call remote component with module name
  • {render_slot(@inner_block)} — render slot content
  • <:slot_name>content</:slot_name> — named slot content

LiveView

defmodule MyAppWeb.SearchLive do
  use MyAppWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok, assign(socket, query: "", results: [])}
  end

  def handle_params(%{"q" => query}, _uri, socket) do
    {:noreply, assign(socket, query: query, results: search(query))}
  end

  def handle_params(_params, _uri, socket), do: {:noreply, socket}

  def handle_event("search", %{"query" => query}, socket) do
    {:noreply,
     socket
     |> assign(query: query, results: search(query))
     |> push_patch(to: ~p"/search?q=#{query}")}
  end

  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <form phx-submit="search">
        <input name="query" value={@query} phx-debounce="300" />
        <button type="submit">Search</button>
      </form>

      <div :for={result <- @results} class="result">
        <h3>{result.title}</h3>
        <p>{result.summary}</p>
      </div>
    </Layouts.app>
    """
  end

  defp search(query), do: MyApp.Search.find(query)
end

LiveView lifecycle: mount/3handle_params/3render/1. Events via handle_event/3. Server pushes via handle_info/2.

Key patterns:

  • assign/2,3 — set socket assigns
  • push_navigate/2 — navigate to new LiveView (full mount)
  • push_patch/2 — update URL without full remount (calls handle_params)
  • push_event/3 — push event to client JS hooks
  • stream/3,4 — efficient list rendering for large collections (inserts/deletes without re-rendering entire list)
  • async_assign/3 + assign_async/3 — async data loading with loading/error states

Channels — Real-Time WebSocket

# In endpoint.ex
socket "/socket", MyAppWeb.UserSocket,
  websocket: true,
  longpoll: false

# lib/my_app_web/channels/user_socket.ex
defmodule MyAppWeb.UserSocket do
  use Phoenix.Socket

  channel "room:*", MyAppWeb.RoomChannel

  def connect(%{"token" => token}, socket, _connect_info) do
    case Phoenix.Token.verify(socket, "user auth", token, max_age: 86400) do
      {:ok, user_id} -> {:ok, assign(socket, :user_id, user_id)}
      {:error, _} -> :error
    end
  end

  def id(socket), do: "users_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, assign(socket, :room_id, room_id)}
  end

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

Force disconnect all sessions for a user:

MyAppWeb.Endpoint.broadcast("users_socket:#{user.id}", "disconnect", %{})

Contexts — Business Logic Boundary

Contexts are plain Elixir modules that encapsulate data access and business rules. They are the API between your web layer and your domain.

# Generate with: mix phx.gen.context Catalog Product products title:string price:decimal
defmodule MyApp.Catalog do
  import Ecto.Query
  alias MyApp.Repo
  alias MyApp.Catalog.Product

  def list_products do
    Repo.all(Product)
  end

  def get_product!(id), do: Repo.get!(Product, id)

  def create_product(attrs \\ %{}) do
    %Product{}
    |> Product.changeset(attrs)
    |> Repo.insert()
  end

  def update_product(%Product{} = product, attrs) do
    product
    |> Product.changeset(attrs)
    |> Repo.update()
  end

  def delete_product(%Product{} = product) do
    Repo.delete(product)
  end

  def change_product(%Product{} = product, attrs \\ %{}) do
    Product.changeset(product, attrs)
  end
end

Context design principles:

  • One context per bounded domain (Catalog, Accounts, Orders)
  • Contexts own their schemas — other contexts reference by ID, not struct
  • Cross-context calls go through the public context API, never access another context's Repo directly
  • Contexts can nest related schemas (Comments under Posts)

Authentication — mix phx.gen.auth

mix phx.gen.auth Accounts User users

Phoenix 1.8 generates:

  • Magic links (passwordless) — email-based login links
  • "Sudo mode" — re-authentication for sensitive actions
  • Session-based auth with secure token handling
  • Email confirmation and password reset flows
  • require_authenticated_user plug for protected routes

Scopes (New in 1.8)

Scopes make secure data access the default in generators. When you generate resources with a scope, all queries are automatically filtered by the scoped user.

mix phx.gen.live Posts Post posts title body:text --scope current_user

This generates code that automatically passes current_user to context functions, ensuring users only see their own data.


Ecto — Database Layer

# Schema
defmodule MyApp.Catalog.Product do
  use Ecto.Schema
  import Ecto.Changeset

  schema "products" do
    field :title, :string
    field :price, :decimal
    field :status, Ecto.Enum, values: [:draft, :published, :archived]
    has_many :reviews, MyApp.Reviews.Review
    belongs_to :category, MyApp.Catalog.Category
    timestamps(type: :utc_datetime)
  end

  def changeset(product, attrs) do
    product
    |> cast(attrs, [:title, :price, :status, :category_id])
    |> validate_required([:title, :price])
    |> validate_number(:price, greater_than: 0)
    |> unique_constraint(:title)
    |> foreign_key_constraint(:category_id)
  end
end

# Queries
import Ecto.Query

# Composable queries
def published(query \\ Product) do
  from p in query, where: p.status == :published
end

def recent(query, days \\ 7) do
  from p in query, where: p.inserted_at > ago(^days, "day")
end

def with_reviews(query) do
  from p in query, preload: [:reviews]
end

# Usage: Product |> published() |> recent(30) |> with_reviews() |> Repo.all()

Telemetry — Built-in Observability

Phoenix 1.8 includes a Telemetry supervisor that tracks request duration, Ecto query times, and VM metrics out of the box.

# lib/my_app_web/telemetry.ex (auto-generated)
defmodule MyAppWeb.Telemetry do
  use Supervisor
  import Telemetry.Metrics

  def metrics do
    [
      summary("phoenix.endpoint.stop.duration", unit: {:native, :millisecond}),
      summary("phoenix.router_dispatch.stop.duration", tags: [:route], unit: {:native, :millisecond}),
      summary("my_app.repo.query.total_time", unit: {:native, :millisecond}),
      summary("vm.memory.total", unit: {:byte, :kilobyte}),
      summary("vm.total_run_queue_lengths.total"),
      summary("vm.total_run_queue_lengths.cpu"),
    ]
  end
end

Integrates with PromEx for Prometheus/Grafana dashboards (see Part 4: Ecosystem).


Security Best Practices

Never pass untrusted input to: Code.eval_string/3, :os.cmd/2, System.cmd/3, System.shell/2, :erlang.binary_to_term/2

Ecto prevents SQL injection by default — the query DSL parameterizes all inputs. Only Ecto.Adapters.SQL.query/4 with raw string interpolation is vulnerable.

Safe deserialization:

# UNSAFE — even with :safe flag
:erlang.binary_to_term(user_input, [:safe])

# SAFE — prevents executable terms
Plug.Crypto.non_executable_binary_to_term(user_input, [:safe])

CSRF protection is built into the :browser pipeline via protect_from_forgery. Content Security Policy is set by put_secure_browser_headers.


Testing Phoenix

# Controller test
defmodule MyAppWeb.ProductControllerTest do
  use MyAppWeb.ConnCase

  test "GET /products", %{conn: conn} do
    conn = get(conn, ~p"/products")
    assert html_response(conn, 200) =~ "Products"
  end

  test "POST /products creates product", %{conn: conn} do
    conn = post(conn, ~p"/products", product: %{title: "Widget", price: 9.99})
    assert redirected_to(conn) =~ ~p"/products/"
  end
end

# LiveView test
defmodule MyAppWeb.CounterLiveTest do
  use MyAppWeb.ConnCase
  import Phoenix.LiveViewTest

  test "increments counter", %{conn: conn} do
    {:ok, view, html} = live(conn, ~p"/counter")
    assert html =~ "Count: 0"

    assert view
           |> element("button", "+1")
           |> render_click() =~ "Count: 1"
  end
end

# Channel test
defmodule MyAppWeb.RoomChannelTest do
  use MyAppWeb.ChannelCase

  test "broadcasts new messages" do
    {:ok, _, socket} = subscribe_and_join(socket(MyAppWeb.UserSocket), MyAppWeb.RoomChannel, "room:lobby")

    push(socket, "new_msg", %{"body" => "hello"})
    assert_broadcast "new_msg", %{body: "hello"}
  end
end

Generators Cheat Sheet

# HTML CRUD (controller + views + templates + context + schema + migration)
mix phx.gen.html Catalog Product products title:string price:decimal

# LiveView CRUD
mix phx.gen.live Catalog Product products title:string price:decimal

# JSON API
mix phx.gen.json Catalog Product products title:string price:decimal

# Context + schema only (no web layer)
mix phx.gen.context Catalog Product products title:string price:decimal

# Schema + migration only
mix phx.gen.schema Product products title:string price:decimal

# Authentication
mix phx.gen.auth Accounts User users

# Channel
mix phx.gen.channel Room

# Presence
mix phx.gen.presence

# Release files (Dockerfile, release.ex, overlay scripts)
mix phx.gen.release
mix phx.gen.release --docker  # Include Dockerfile

Release & Deployment

# Generate release infrastructure
mix phx.gen.release --docker

# Build release
MIX_ENV=prod mix deps.get --only prod
MIX_ENV=prod mix compile
MIX_ENV=prod mix assets.deploy
MIX_ENV=prod mix release

# Release commands
_build/prod/rel/my_app/bin/server     # Start with Phoenix server
_build/prod/rel/my_app/bin/migrate    # Run migrations
_build/prod/rel/my_app/bin/my_app remote  # Attach IEx console

Runtime config (config/runtime.exs):

import Config

if config_env() == :prod do
  database_url = System.fetch_env!("DATABASE_URL")
  secret_key_base = System.fetch_env!("SECRET_KEY_BASE")

  config :my_app, MyApp.Repo,
    url: database_url,
    pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")

  config :my_app, MyAppWeb.Endpoint,
    url: [host: System.fetch_env!("PHX_HOST"), port: 443, scheme: "https"],
    http: [ip: {0, 0, 0, 0}, port: String.to_integer(System.get_env("PORT") || "4000")],
    secret_key_base: secret_key_base
end