Tech Guides
01

Quick Reference

Essential Mix commands, IEx helpers, and the core data types you will use daily.

Mix Commands

Mix is Elixir's build tool -- project scaffolding, dependency management, and task runner in one.

mix new my_app
Create a new project with OTP supervision tree
mix new my_app --sup
New project with Application supervisor
mix deps.get
Fetch all dependencies from mix.exs
mix compile
Compile the project (incremental)
mix test
Run ExUnit test suite
mix format
Auto-format all .ex and .exs files
mix hex.info package
Look up a Hex.pm package
mix phx.new my_web
Generate a Phoenix project

IEx -- Interactive Elixir

The Elixir REPL supports auto-complete, inline docs, and runtime debugging.

# Start IEx with your project loaded
iex -S mix

# Inline documentation
iex> h Enum.map/2
iex> h String.split

# Recompile a module without restarting
iex> r MyModule

# Value inspection
iex> i [1, 2, 3]
# Term:   [1, 2, 3]
# Type:   List
# Length: 3

# Inspect process info
iex> Process.info(self())

# Break out of incomplete expression
iex> #iex:break

# IEx helpers
iex> exports Enum     # list public functions
iex> open Enum.map/2  # open source in editor

Core Data Types

Type Example Notes
integer 42, 0xFF, 1_000_000 Arbitrary precision, no overflow
float 3.14, 1.0e-3 64-bit IEEE 754 doubles
atom :ok, :error, true Named constants (booleans are atoms)
string "hello #{name}" UTF-8 binaries with interpolation
list [1, 2, 3] Linked lists -- prepend is O(1)
tuple {:ok, value} Fixed-size, contiguous memory
map %{key: "val"} Key-value store, any key type
keyword [name: "Jo", age: 30] List of 2-tuples, allows duplicate keys
struct %User{name: "Jo"} Tagged map with compile-time checks
pid #PID<0.110.0> Process identifier

mix.exs Project File

defmodule MyApp.MixProject do
  use Mix.Project

  def project do
    [
      app: :my_app,
      version: "0.1.0",
      elixir: "~> 1.17",
      start_permanent: Mix.env() == :prod,
      deps: deps(),
      aliases: aliases()
    ]
  end

  def application do
    [
      extra_applications: [:logger],
      mod: {MyApp.Application, []}
    ]
  end

  defp deps do
    [
      {:phoenix, "~> 1.7"},
      {:ecto_sql, "~> 3.12"},
      {:jason, "~> 1.4"},
      {:credo, "~> 1.7", only: [:dev, :test], runtime: false}
    ]
  end

  defp aliases do
    [
      setup: ["deps.get", "ecto.setup"],
      "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
      "ecto.reset": ["ecto.drop", "ecto.setup"],
      test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"]
    ]
  end
end
02

Pattern Matching & Immutability

The = operator is a match, not an assignment. Data never mutates -- you transform it.

The Match Operator

In Elixir, = asserts that the left-hand side matches the right-hand side. Variables on the left are bound; literals must match exactly.

# Simple binding
x = 42

# Destructuring tuples
{:ok, result} = {:ok, "hello"}
# result => "hello"

# Destructuring lists
[head | tail] = [1, 2, 3, 4]
# head => 1, tail => [2, 3, 4]

# Pinning with ^ to match existing value
x = 1
^x = 1    # matches
^x = 2    # ** (MatchError)

# Ignoring values with _
{:ok, _, important} = {:ok, "skip me", 42}

# Nested patterns
%{user: %{name: name}} = %{user: %{name: "Alice", age: 30}}
# name => "Alice"

Pattern Matching in Functions

Function heads use pattern matching for control flow -- no if/else spaghetti needed.

defmodule Greeter do
  def hello(%{name: name, lang: "en"}), do: "Hello, #{name}!"
  def hello(%{name: name, lang: "es"}), do: "Hola, #{name}!"
  def hello(%{name: name, lang: "ja"}), do: "Konnichiwa, #{name}!"
  def hello(%{name: name}),             do: "Hi, #{name}!"
end

Greeter.hello(%{name: "Ada", lang: "es"})
# => "Hola, Ada!"

Case, Cond, With

# case -- pattern match on a value
case File.read("config.json") do
  {:ok, contents} ->
    Jason.decode!(contents)
  {:error, :enoent} ->
    %{}  # default config
  {:error, reason} ->
    raise "Cannot read config: #{reason}"
end

# cond -- first truthy condition wins
cond do
  age < 13 -> "child"
  age < 18 -> "teen"
  true     -> "adult"
end

# with -- chain happy-path matches
with {:ok, user}    <- fetch_user(id),
     {:ok, account} <- fetch_account(user),
     {:ok, balance} <- check_balance(account) do
  {:ok, balance}
else
  {:error, reason} -> {:error, reason}
end

Guards

Guards add conditions to pattern matches. Only a limited set of expressions is allowed.

def serve(drink) when drink in [:coffee, :tea], do: "Hot drink"
def serve(drink) when drink in [:water, :juice], do: "Cold drink"
def serve(_), do: "Unknown"

# Custom guards
defguard is_adult(age) when is_integer(age) and age >= 18

def can_vote?(age) when is_adult(age), do: true
def can_vote?(_), do: false

# Allowed in guards: comparison, boolean, arithmetic,
# type checks (is_atom, is_binary, etc.), and a few builtins

Immutability

Core Principle
All data in Elixir is immutable. Operations return new values -- the originals are never modified. This eliminates race conditions and makes concurrent code safe by default.
list = [1, 2, 3]
new_list = [0 | list]     # [0, 1, 2, 3] -- list is unchanged

map = %{a: 1, b: 2}
new_map = %{map | a: 10}  # %{a: 10, b: 2} -- map is unchanged

# Structs follow the same rule
user = %User{name: "Jo", age: 30}
older = %{user | age: 31} # new struct -- user is unchanged
03

Modules, Functions & Pipes

Organize code into modules. Compose with the pipe operator. Embrace first-class functions.

Modules

defmodule MyApp.Math do
  @moduledoc """
  Mathematical utilities.
  """

  @pi 3.14159265358979

  @doc """
  Calculates the area of a circle.
  """
  @spec circle_area(number()) :: float()
  def circle_area(radius) when radius >= 0 do
    @pi * radius * radius
  end

  # Private function -- only visible inside this module
  defp square(x), do: x * x
end

Anonymous Functions & Captures

# Anonymous function
add = fn a, b -> a + b end
add.(3, 4)  # => 7

# Shorthand capture syntax
add = &(&1 + &2)
add.(3, 4)  # => 7

# Capture a named function
upcase = &String.upcase/1
upcase.("hello")  # => "HELLO"

# Pass functions to higher-order functions
Enum.map([1, 2, 3], fn x -> x * 2 end)  # [2, 4, 6]
Enum.map([1, 2, 3], &(&1 * 2))           # [2, 4, 6]
Enum.filter(1..10, &(rem(&1, 2) == 0))   # [2, 4, 6, 8, 10]

The Pipe Operator |>

The pipe takes the result of the left expression and passes it as the first argument to the right function. It turns nested calls into readable top-to-bottom pipelines.

# Without pipes (read inside-out)
String.split(String.upcase(String.trim("  hello world  ")))

# With pipes (read top-to-bottom)
"  hello world  "
|> String.trim()
|> String.upcase()
|> String.split()
# => ["HELLO", "WORLD"]

# Real-world pipeline
defmodule Orders do
  def process(params) do
    params
    |> validate()
    |> calculate_total()
    |> apply_discount()
    |> charge_payment()
    |> send_confirmation()
  end
end
Pipe Tip
Design your functions so the primary data structure is always the first argument. This makes every function pipeable.

Enum & Stream

# Enum -- eager, processes the whole collection
Enum.map([1, 2, 3], &(&1 * 2))         # [2, 4, 6]
Enum.reduce([1, 2, 3], 0, &+/2)         # 6
Enum.flat_map([[1], [2, 3]], &(&1))      # [1, 2, 3]
Enum.group_by(~w(ant bee cat), &String.length/1)
# %{3 => ["ant", "bee", "cat"]}

Enum.chunk_every(1..10, 3)
# [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10]]

Enum.zip([:a, :b, :c], [1, 2, 3])
# [a: 1, b: 2, c: 3]

# Stream -- lazy, composes transformations
1..1_000_000
|> Stream.filter(&(rem(&1, 2) == 0))
|> Stream.map(&(&1 * 3))
|> Enum.take(5)
# => [6, 12, 18, 24, 30]

# Infinite streams
Stream.iterate(0, &(&1 + 1))
|> Stream.filter(&(rem(&1, 3) == 0))
|> Enum.take(5)
# => [0, 3, 6, 9, 12]

Protocols & Behaviours

# Protocol -- polymorphism for data types
defprotocol Stringify do
  @doc "Convert any value to a display string"
  def to_s(value)
end

defimpl Stringify, for: Integer do
  def to_s(n), do: Integer.to_string(n)
end

defimpl Stringify, for: Map do
  def to_s(map), do: inspect(map)
end

# Behaviour -- interface contract for modules
defmodule MyApp.Cache do
  @callback get(key :: String.t()) :: {:ok, term()} | :miss
  @callback put(key :: String.t(), value :: term()) :: :ok
end

defmodule MyApp.ETSCache do
  @behaviour MyApp.Cache

  @impl true
  def get(key), do: # ...

  @impl true
  def put(key, value), do: # ...
end
04

OTP: Processes & Supervisors

Lightweight processes, GenServer state machines, and supervision trees that let it crash and recover.

Processes

Elixir processes are extremely lightweight (a few KB each). They communicate via message passing and share nothing.

# Spawn a process
pid = spawn(fn ->
  receive do
    {:greet, name} -> IO.puts("Hello, #{name}!")
  end
end)

# Send a message
send(pid, {:greet, "World"})

# Linked processes -- crash propagation
pid = spawn_link(fn ->
  raise "oops"
end)
# The linked process crash takes down the parent too

# Monitor -- observe without crashing together
ref = Process.monitor(pid)
receive do
  {:DOWN, ^ref, :process, ^pid, reason} ->
    IO.puts("Process died: #{reason}")
end

GenServer

GenServer abstracts the receive loop into a behaviour with callbacks for synchronous calls, async casts, and info messages.

defmodule MyApp.Counter do
  use GenServer

  # Client API
  def start_link(initial \\ 0) do
    GenServer.start_link(__MODULE__, initial, name: __MODULE__)
  end

  def increment, do: GenServer.cast(__MODULE__, :increment)
  def decrement, do: GenServer.cast(__MODULE__, :decrement)
  def value,     do: GenServer.call(__MODULE__, :value)

  # Server Callbacks
  @impl true
  def init(initial), do: {:ok, initial}

  @impl true
  def handle_cast(:increment, count), do: {:noreply, count + 1}
  def handle_cast(:decrement, count), do: {:noreply, count - 1}

  @impl true
  def handle_call(:value, _from, count) do
    {:reply, count, count}
  end
end

# Usage
{:ok, _pid} = MyApp.Counter.start_link(0)
MyApp.Counter.increment()
MyApp.Counter.increment()
MyApp.Counter.value()   # => 2

Supervisors

Supervisors watch child processes and restart them according to a strategy when they crash.

defmodule MyApp.Application do
  use Application

  @impl true
  def start(_type, _args) do
    children = [
      # Simple child
      MyApp.Counter,

      # Child with options
      {MyApp.Cache, max_size: 1000},

      # Dynamic supervisor for on-demand processes
      {DynamicSupervisor, name: MyApp.DynSup, strategy: :one_for_one},

      # Task supervisor for fire-and-forget work
      {Task.Supervisor, name: MyApp.TaskSup}
    ]

    opts = [strategy: :one_for_one, name: MyApp.Supervisor]
    Supervisor.start_link(children, opts)
  end
end
Strategy Behaviour Use When
:one_for_one Only the crashed child restarts Children are independent
:one_for_all All children restart Children depend on each other
:rest_for_one Crashed child + children started after it restart Sequential dependency chain

Other OTP Abstractions

# Agent -- simple state wrapper
{:ok, pid} = Agent.start_link(fn -> %{} end)
Agent.update(pid, &Map.put(&1, :name, "Alice"))
Agent.get(pid, &Map.get(&1, :name))  # => "Alice"

# Task -- async one-off work
task = Task.async(fn -> expensive_computation() end)
result = Task.await(task, 5_000)  # 5s timeout

# Task.Supervisor for fault-tolerant tasks
Task.Supervisor.async_nolink(MyApp.TaskSup, fn ->
  send_email(user)
end)

# Registry -- process name registry
{:ok, _} = Registry.start_link(keys: :unique, name: MyApp.Registry)
Registry.register(MyApp.Registry, "room:lobby", %{})
05

Phoenix Framework

The productive web framework built on OTP. Channels, contexts, and convention-over-configuration routing.

Project Setup

# Install the Phoenix generator
mix archive.install hex phx_new

# Create a new Phoenix project
mix phx.new my_app

# Create without Ecto (no database)
mix phx.new my_app --no-ecto

# Create API-only (no HTML views)
mix phx.new my_app --no-html --no-assets

# Start the dev server
cd my_app
mix setup          # deps.get + ecto.create + ecto.migrate
mix phx.server     # http://localhost:4000
iex -S mix phx.server  # with IEx attached

Project Structure

my_app/
  lib/
    my_app/           # Business logic (contexts)
      accounts/       # Accounts context
        user.ex       # Ecto schema
      accounts.ex     # Context module (public API)
      repo.ex         # Ecto Repo
      application.ex  # OTP Application
    my_app_web/       # Web layer
      controllers/    # HTTP controllers
      components/     # Function components (HEEx)
      live/           # LiveView modules
      router.ex       # Routes
      endpoint.ex     # Plug pipeline
  priv/
    repo/migrations/  # Database migrations
    static/           # Static assets
  test/               # Tests mirror lib/ structure
  config/             # Environment configs

Router

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 "/users", UserLive.Index, :index
    live "/users/:id", UserLive.Show, :show

    resources "/posts", PostController
  end

  scope "/api", MyAppWeb.Api do
    pipe_through :api

    resources "/articles", ArticleController, except: [:new, :edit]
  end
end

Controllers & JSON APIs

defmodule MyAppWeb.Api.ArticleController do
  use MyAppWeb, :controller

  alias MyApp.Blog

  def index(conn, _params) do
    articles = Blog.list_articles()
    json(conn, %{data: articles})
  end

  def show(conn, %{"id" => id}) do
    case Blog.get_article(id) do
      nil ->
        conn
        |> put_status(:not_found)
        |> json(%{error: "Not found"})
      article ->
        json(conn, %{data: article})
    end
  end

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

Contexts

Contexts are boundary modules that expose a clean public API for a domain. They hide Ecto queries and internal details behind named functions.

defmodule MyApp.Accounts do
  @moduledoc "The Accounts context -- user management."

  alias MyApp.Repo
  alias MyApp.Accounts.User

  def list_users, do: Repo.all(User)

  def get_user!(id), do: Repo.get!(User, id)

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

  def update_user(%User{} = user, attrs) do
    user
    |> User.changeset(attrs)
    |> Repo.update()
  end

  def delete_user(%User{} = user) do
    Repo.delete(user)
  end
end
Generators
Use mix phx.gen.context Accounts User users name:string email:string to scaffold a context, schema, and migration in one command.
06

LiveView

Real-time, server-rendered UIs over WebSocket. Rich interactivity without writing JavaScript.

How It Works

  • Initial request renders full HTML (fast first paint, SEO-friendly)
  • WebSocket connects and sends events (clicks, form submits, key presses)
  • Server processes the event, updates assigns, computes a diff
  • Only the changed parts of the DOM are patched on the client
  • No custom JavaScript needed for most interactions

Basic LiveView

defmodule MyAppWeb.CounterLive do
  use MyAppWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok, assign(socket, count: 0)}
  end

  def handle_event("increment", _params, socket) do
    {:noreply, update(socket, :count, &(&1 + 1))}
  end

  def handle_event("decrement", _params, socket) do
    {:noreply, update(socket, :count, &(&1 - 1))}
  end

  def render(assigns) do
    ~H"""
    <div class="counter">
      <h1>Count: <%= @count %></h1>
      <button phx-click="decrement">-</button>
      <button phx-click="increment">+</button>
    </div>
    """
  end
end

LiveView Forms

defmodule MyAppWeb.UserFormLive do
  use MyAppWeb, :live_view

  alias MyApp.Accounts
  alias MyApp.Accounts.User

  def mount(_params, _session, socket) do
    changeset = Accounts.change_user(%User{})
    {:ok, assign(socket, form: to_form(changeset))}
  end

  def handle_event("validate", %{"user" => params}, socket) do
    changeset =
      %User{}
      |> User.changeset(params)
      |> Map.put(:action, :validate)

    {:noreply, assign(socket, form: to_form(changeset))}
  end

  def handle_event("save", %{"user" => params}, socket) do
    case Accounts.create_user(params) do
      {:ok, _user} ->
        {:noreply,
          socket
          |> put_flash(:info, "User created!")
          |> redirect(to: ~p"/users")}

      {:error, changeset} ->
        {:noreply, assign(socket, form: to_form(changeset))}
    end
  end

  def render(assigns) do
    ~H"""
    <.form for={@form} phx-change="validate" phx-submit="save">
      <.input field={@form[:name]} label="Name" />
      <.input field={@form[:email]} type="email" label="Email" />
      <.button>Save</.button>
    </.form>
    """
  end
end

Real-Time with PubSub

# Subscribe to a topic on mount
def mount(_params, _session, socket) do
  if connected?(socket) do
    Phoenix.PubSub.subscribe(MyApp.PubSub, "notifications")
  end
  {:ok, assign(socket, notifications: [])}
end

# Handle broadcast messages
def handle_info({:new_notification, notif}, socket) do
  {:noreply, update(socket, :notifications, &[notif | &1])}
end

# Broadcast from anywhere in your app
Phoenix.PubSub.broadcast(
  MyApp.PubSub,
  "notifications",
  {:new_notification, %{text: "New order!", at: DateTime.utc_now()}}
)

Function Components

defmodule MyAppWeb.CoreComponents do
  use Phoenix.Component

  attr :type, :string, default: "button"
  attr :class, :string, default: nil
  slot :inner_block, required: true

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

  attr :label, :string, required: true
  attr :items, :list, required: true
  slot :col, required: true do
    attr :label, :string, required: true
  end

  def data_table(assigns) do
    ~H"""
    <table>
      <thead>
        <tr>
          <th :for={col <- @col}><%= col.label %></th>
        </tr>
      </thead>
      <tbody>
        <tr :for={item <- @items}>
          <td :for={col <- @col}><%= render_slot(col, item) %></td>
        </tr>
      </tbody>
    </table>
    """
  end
end
07

Ecto

Database toolkit and query language. Schemas, changesets, migrations, and composable queries.

Schema & Changeset

defmodule MyApp.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :name, :string
    field :email, :string
    field :age, :integer
    field :role, Ecto.Enum, values: [:admin, :user, :guest]
    has_many :posts, MyApp.Blog.Post
    belongs_to :organization, MyApp.Orgs.Organization

    timestamps()  # inserted_at, updated_at
  end

  def changeset(user, attrs) do
    user
    |> cast(attrs, [:name, :email, :age, :role])
    |> validate_required([:name, :email])
    |> validate_format(:email, ~r/@/)
    |> validate_number(:age, greater_than: 0, less_than: 150)
    |> validate_length(:name, min: 2, max: 100)
    |> unique_constraint(:email)
  end
end
Changesets
Changesets are the core of Ecto validation. They track changes, cast params, validate constraints, and accumulate errors -- all without touching the database.

Queries

import Ecto.Query

# Simple queries
Repo.all(User)
Repo.get!(User, 1)
Repo.get_by(User, email: "alice@example.com")

# Keyword syntax
from(u in User,
  where: u.age >= 18,
  order_by: [desc: u.inserted_at],
  limit: 10,
  select: %{name: u.name, email: u.email}
)
|> Repo.all()

# Pipe syntax (composable)
User
|> where([u], u.role == :admin)
|> where([u], u.age >= ^min_age)
|> order_by([u], desc: u.name)
|> preload(:posts)
|> Repo.all()

# Aggregates
from(u in User, select: count(u.id)) |> Repo.one()
from(u in User, select: avg(u.age)) |> Repo.one()

# Joins
from(u in User,
  join: p in assoc(u, :posts),
  where: p.published == true,
  group_by: u.id,
  having: count(p.id) > 5,
  select: {u.name, count(p.id)}
)
|> Repo.all()

Migrations

# Generate: mix ecto.gen.migration create_users
defmodule MyApp.Repo.Migrations.CreateUsers do
  use Ecto.Migration

  def change do
    create table(:users) do
      add :name, :string, null: false
      add :email, :string, null: false
      add :age, :integer
      add :role, :string, default: "user"
      add :organization_id, references(:organizations, on_delete: :delete_all)

      timestamps()
    end

    create unique_index(:users, [:email])
    create index(:users, [:organization_id])
  end
end

# Run migrations
mix ecto.migrate
mix ecto.rollback       # undo last migration
mix ecto.reset          # drop + create + migrate

Transactions & Multi

# Simple transaction
Repo.transaction(fn ->
  user = Repo.insert!(%User{name: "Alice"})
  Repo.insert!(%Post{user_id: user.id, title: "Hello"})
end)

# Ecto.Multi -- composable, named transaction steps
alias Ecto.Multi

Multi.new()
|> Multi.insert(:user, User.changeset(%User{}, user_params))
|> Multi.insert(:profile, fn %{user: user} ->
  Profile.changeset(%Profile{}, %{user_id: user.id})
end)
|> Multi.run(:welcome_email, fn _repo, %{user: user} ->
  Mailer.send_welcome(user)
end)
|> Repo.transaction()
|> case do
  {:ok, %{user: user, profile: profile}} -> # success
  {:error, :user, changeset, _} ->          # user insert failed
  {:error, :welcome_email, reason, _} ->    # email failed
end
08

Testing with ExUnit

Built-in test framework. Doctests, async tests, mocks with Mox, and Phoenix test helpers.

Basic Tests

defmodule MyApp.MathTest do
  use ExUnit.Case, async: true

  describe "add/2" do
    test "adds two positive numbers" do
      assert MyApp.Math.add(2, 3) == 5
    end

    test "handles negative numbers" do
      assert MyApp.Math.add(-1, 1) == 0
    end

    test "raises on non-numbers" do
      assert_raise ArithmeticError, fn ->
        MyApp.Math.add("a", 1)
      end
    end
  end
end

Doctests

Elixir can extract and run tests from your documentation comments.

defmodule MyApp.Math do
  @doc """
  Adds two numbers.

      iex> MyApp.Math.add(2, 3)
      5

      iex> MyApp.Math.add(-1, 1)
      0
  """
  def add(a, b), do: a + b
end

# In the test file
defmodule MyApp.MathTest do
  use ExUnit.Case, async: true
  doctest MyApp.Math
end

Setup & Fixtures

defmodule MyApp.AccountsTest do
  use MyApp.DataCase

  alias MyApp.Accounts

  # Runs before each test in this module
  setup do
    user = Accounts.create_user!(%{name: "Test", email: "test@example.com"})
    %{user: user}
  end

  # setup_all runs once for the entire module
  setup_all do
    %{shared_data: "available to all tests"}
  end

  # The setup return value is merged into the test context
  test "get_user!/1 returns the user", %{user: user} do
    assert Accounts.get_user!(user.id) == user
  end

  test "update_user/2 with valid data", %{user: user} do
    assert {:ok, updated} = Accounts.update_user(user, %{name: "Updated"})
    assert updated.name == "Updated"
  end

  test "delete_user/1 removes the user", %{user: user} do
    assert {:ok, _} = Accounts.delete_user(user)
    assert_raise Ecto.NoResultsError, fn -> Accounts.get_user!(user.id) end
  end
end

Testing Phoenix LiveView

defmodule MyAppWeb.CounterLiveTest do
  use MyAppWeb.ConnCase

  import Phoenix.LiveViewTest

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

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

  test "validates form on change", %{conn: conn} do
    {:ok, view, _html} = live(conn, "/users/new")

    assert view
           |> form("#user-form", user: %{email: "bad"})
           |> render_change() =~ "is invalid"
  end

  test "creates user on submit", %{conn: conn} do
    {:ok, view, _html} = live(conn, "/users/new")

    view
    |> form("#user-form", user: %{name: "Alice", email: "alice@test.com"})
    |> render_submit()

    assert_redirect(view, "/users")
  end
end

Mix Test Commands

mix test
Run all tests
mix test test/my_app/math_test.exs
Run a specific test file
mix test test/my_app_test.exs:42
Run test at a specific line
mix test --stale
Only tests for changed modules
mix test --failed
Re-run only previously failed tests
mix test --cover
Generate code coverage report
09

Deployment

Elixir releases, Docker builds, and deploying to Fly.io -- from mix release to production.

Mix Releases

Releases bundle your compiled application, the Erlang runtime, and all dependencies into a self-contained directory.

# Build a release
MIX_ENV=prod mix release

# The release is in _build/prod/rel/my_app/
# Start it:
_build/prod/rel/my_app/bin/my_app start

# Other commands:
_build/prod/rel/my_app/bin/my_app stop
_build/prod/rel/my_app/bin/my_app restart
_build/prod/rel/my_app/bin/my_app remote   # attach IEx
_build/prod/rel/my_app/bin/my_app eval "MyApp.Release.migrate()"

Runtime Configuration

# config/runtime.exs -- read at runtime (not compile time)
import Config

if config_env() == :prod do
  database_url =
    System.get_env("DATABASE_URL") ||
      raise "DATABASE_URL not set"

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

  secret_key_base =
    System.get_env("SECRET_KEY_BASE") ||
      raise "SECRET_KEY_BASE not set"

  config :my_app, MyAppWeb.Endpoint,
    url: [host: System.get_env("PHX_HOST") || "example.com", port: 443, scheme: "https"],
    http: [port: String.to_integer(System.get_env("PORT") || "4000")],
    secret_key_base: secret_key_base
end

Dockerfile

Phoenix ships a production-ready Dockerfile. Generate it with mix phx.gen.release --docker.

# Multi-stage Dockerfile (simplified)
ARG ELIXIR_VERSION=1.17.0
ARG OTP_VERSION=27.0
ARG DEBIAN_VERSION=bookworm-20240612-slim

ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"

# Build stage
FROM ${BUILDER_IMAGE} as builder

ENV MIX_ENV="prod"
WORKDIR /app

RUN mix local.hex --force && mix local.rebar --force

COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV
RUN mkdir config
COPY config/config.exs config/${MIX_ENV}.exs config/
RUN mix deps.compile

COPY priv priv
COPY lib lib
COPY assets assets
RUN mix assets.deploy
RUN mix compile
RUN mix release

# Runtime stage
FROM ${RUNNER_IMAGE}

RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales \
  && apt-get clean && rm -f /var/lib/apt/lists/*_*

WORKDIR /app
COPY --from=builder /app/_build/prod/rel/my_app ./

ENV PHX_SERVER=true
CMD ["/app/bin/server"]

Deploy to Fly.io

# Install flyctl and authenticate
curl -L https://fly.io/install.sh | sh
fly auth login

# Launch a new app (auto-detects Phoenix)
fly launch
# Detects Dockerfile, creates fly.toml, provisions Postgres

# Set secrets (environment variables)
fly secrets set SECRET_KEY_BASE=$(mix phx.gen.secret)
fly secrets set DATABASE_URL=postgres://...

# Deploy
fly deploy

# Run migrations on Fly
fly ssh console -C "/app/bin/migrate"

# Scale
fly scale count 2       # 2 instances
fly scale vm shared-cpu-2x  # bigger VM

# Useful commands
fly logs                 # tail production logs
fly ssh console          # SSH into the running VM
fly status               # app health + instances
fly postgres connect     # psql into managed Postgres

Release Migration Module

Since you cannot run Mix in production, embed a migration runner in your release.

defmodule MyApp.Release do
  @moduledoc """
  Tasks that can be run from the release (no Mix available).
  """
  @app :my_app

  def migrate do
    load_app()

    for repo <- repos() do
      {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
    end
  end

  def rollback(repo, version) do
    load_app()
    {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
  end

  defp repos do
    Application.fetch_env!(@app, :ecto_repos)
  end

  defp load_app do
    Application.load(@app)
  end
end

Production Checklist

Before Going Live
  • Set MIX_ENV=prod for all build steps
  • Generate a strong SECRET_KEY_BASE with mix phx.gen.secret
  • Configure config/runtime.exs to read from environment variables
  • Enable SSL in your endpoint or use a reverse proxy
  • Run mix phx.digest (or mix assets.deploy) to hash static assets
  • Set pool_size appropriate for your database connections
  • Configure log level to :info or :warning in production
  • Add health check endpoint for load balancers