Phoenix Web Framework

Master Phoenix - Elixir's web framework for building fast, scalable web applications with real-time features.

What is Phoenix?

Phoenix is a web framework that provides:

  • MVC architecture: Controllers, views, templates
  • Real-time: Channels for WebSocket communication
  • LiveView: Real-time UIs without JavaScript
  • Plugs: Composable middleware
  • Ecto integration: Database access
  • Fast: Sub-millisecond response times

Installation

Install Phoenix

mix archive.install hex phx_new

Create New Project

# Full application with database
mix phx.new myapp

# API-only (no HTML/assets)
mix phx.new myapp --no-html --no-assets

# Without Ecto
mix phx.new myapp --no-ecto

# With LiveView
mix phx.new myapp --live

Setup Project

cd myapp

# Install dependencies
mix deps.get

# Create database
mix ecto.create

# Run migrations
mix ecto.migrate

# Start server
mix phx.server

# Start with IEx
iex -S mix phx.server

Visit: http://localhost:4000

Project Structure

myapp/
├── assets/          # JavaScript, CSS
├── config/          # Configuration
├── lib/
│   ├── myapp/       # Business logic
│   │   ├── accounts/
│   │   │   └── user.ex
│   │   └── repo.ex
│   └── myapp_web/   # Web layer
│       ├── controllers/
│       ├── views/
│       ├── templates/
│       ├── channels/
│       ├── router.ex
│       └── endpoint.ex
├── priv/
│   └── repo/migrations/
└── test/

Router

Define URL routes and dispatch to controllers.

Basic Routes

# lib/myapp_web/router.ex
defmodule MyappWeb.Router do
  use MyappWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, {MyappWeb.LayoutView, :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, :index
    get "/about", PageController, :about
    resources "/posts", PostController
  end

  scope "/api", MyappWeb do
    pipe_through :api

    resources "/users", UserController, except: [:new, :edit]
  end
end

RESTful Resources

# resources "/posts", PostController generates:
# GET     /posts           -> index
# GET     /posts/new       -> new
# POST    /posts           -> create
# GET     /posts/:id       -> show
# GET     /posts/:id/edit  -> edit
# PATCH   /posts/:id       -> update
# PUT     /posts/:id       -> update
# DELETE  /posts/:id       -> delete

# Customize routes
resources "/posts", PostController, only: [:index, :show]
resources "/posts", PostController, except: [:delete]

Nested Resources

resources "/users", UserController do
  resources "/posts", PostController
end

# Generates:
# /users/:user_id/posts
# /users/:user_id/posts/:id

Scoped Routes

scope "/admin", MyappWeb.Admin, as: :admin do
  pipe_through [:browser, :require_admin]

  resources "/users", UserController
  resources "/posts", PostController
end

# Routes:
# admin_user_path -> /admin/users

Controllers

Handle HTTP requests and return responses.

Basic Controller

defmodule MyappWeb.PageController do
  use MyappWeb, :controller

  def index(conn, _params) do
    render(conn, "index.html")
  end

  def about(conn, _params) do
    render(conn, "about.html")
  end
end

With Parameters

defmodule MyappWeb.PostController do
  use MyappWeb, :controller
  alias Myapp.Blog

  def index(conn, _params) do
    posts = Blog.list_posts()
    render(conn, "index.html", posts: posts)
  end

  def show(conn, %{"id" => id}) do
    post = Blog.get_post!(id)
    render(conn, "show.html", post: post)
  end

  def create(conn, %{"post" => post_params}) do
    case Blog.create_post(post_params) do
      {:ok, post} ->
        conn
        |> put_flash(:info, "Post created successfully")
        |> redirect(to: Routes.post_path(conn, :show, post))

      {:error, changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end
end

JSON API

defmodule MyappWeb.API.UserController do
  use MyappWeb, :controller

  def index(conn, _params) do
    users = Accounts.list_users()
    json(conn, %{data: users})
  end

  def show(conn, %{"id" => id}) do
    user = Accounts.get_user!(id)
    json(conn, %{data: user})
  end

  def create(conn, %{"user" => user_params}) do
    case Accounts.create_user(user_params) do
      {:ok, user} ->
        conn
        |> put_status(:created)
        |> json(%{data: user})

      {:error, changeset} ->
        conn
        |> put_status(:unprocessable_entity)
        |> json(%{errors: translate_errors(changeset)})
    end
  end
end

Redirects

# Redirect to path
redirect(conn, to: "/posts")

# Redirect to route
redirect(conn, to: Routes.post_path(conn, :index))

# Redirect with status
redirect(conn, to: "/", permanent: true)  # 301

Flash Messages

conn
|> put_flash(:info, "Post created!")
|> put_flash(:error, "Something went wrong")
|> redirect(to: "/posts")

Views and Templates

Render HTML responses.

View

# lib/myapp_web/views/post_view.ex
defmodule MyappWeb.PostView do
  use MyappWeb, :view

  def format_date(date) do
    Calendar.strftime(date, "%B %d, %Y")
  end

  def truncate(text, length) do
    if String.length(text) > length do
      String.slice(text, 0, length) <> "..."
    else
      text
    end
  end
end

Template

<!-- lib/myapp_web/templates/post/index.html.heex -->
<h1>Posts</h1>

<table>
  <%= for post <- @posts do %>
    <tr>
      <td><%= post.title %></td>
      <td><%= format_date(post.inserted_at) %></td>
      <td>
        <%= link "Show", to: Routes.post_path(@conn, :show, post) %>
        <%= link "Edit", to: Routes.post_path(@conn, :edit, post) %>
      </td>
    </tr>
  <% end %>
</table>

<%= link "New Post", to: Routes.post_path(@conn, :new) %>

Forms

<!-- lib/myapp_web/templates/post/new.html.heex -->
<h1>New Post</h1>

<%= form_for @changeset, Routes.post_path(@conn, :create), fn f -> %>
  <%= if @changeset.action do %>
    <div class="alert alert-danger">
      <p>Oops, something went wrong!</p>
    </div>
  <% end %>

  <div>
    <%= label f, :title %>
    <%= text_input f, :title %>
    <%= error_tag f, :title %>
  </div>

  <div>
    <%= label f, :body %>
    <%= textarea f, :body %>
    <%= error_tag f, :body %>
  </div>

  <div>
    <%= submit "Save" %>
  </div>
<% end %>

Plugs

Composable modules for transforming connections.

Function Plugs

defmodule MyappWeb.Plugs.RequireAuth do
  import Plug.Conn
  import Phoenix.Controller

  def init(opts), do: opts

  def call(conn, _opts) do
    if get_session(conn, :user_id) do
      conn
    else
      conn
      |> put_flash(:error, "You must be logged in")
      |> redirect(to: "/login")
      |> halt()
    end
  end
end

Use in Router

pipeline :auth do
  plug MyappWeb.Plugs.RequireAuth
end

scope "/admin" do
  pipe_through [:browser, :auth]
  
  resources "/posts", PostController
end

Use in Controller

defmodule MyappWeb.PostController do
  use MyappWeb, :controller

  plug MyappWeb.Plugs.RequireAuth when action in [:new, :create, :edit, :update, :delete]

  # ...
end

LiveView

Real-time, interactive UIs without JavaScript.

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", _value, socket) do
    {:noreply, update(socket, :count, &(&1 + 1))}
  end

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

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

Route LiveView

# router.ex
scope "/" do
  pipe_through :browser

  live "/counter", CounterLive
end

Form LiveView

defmodule MyappWeb.SearchLive do
  use MyappWeb, :live_view

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

  def handle_event("search", %{"query" => query}, socket) do
    results = MyApp.search(query)
    {:noreply, assign(socket, query: query, results: results)}
  end

  def render(assigns) do
    ~H"""
    <form phx-change="search">
      <input type="text" name="query" value={@query} placeholder="Search..." />
    </form>

    <ul>
      <%= for result <- @results do %>
        <li><%= result.name %></li>
      <% end %>
    </ul>
    """
  end
end

LiveView Events

# Handle clicks
<button phx-click="save">Save</button>

# Handle form submission
<form phx-submit="create">

# Handle input changes
<input phx-change="validate" />

# Handle key events
<input phx-keydown="key_pressed" />

# Target specific event
<button phx-click="delete" phx-value-id={@post.id}>Delete</button>

Channels

Real-time bidirectional communication via WebSockets.

Define Channel

defmodule MyappWeb.RoomChannel do
  use MyappWeb, :channel

  def join("room:" <> room_id, _payload, 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: socket.assigns.user_id})
    {:noreply, socket}
  end

  def handle_out("new_msg", payload, socket) do
    push(socket, "new_msg", payload)
    {:noreply, socket}
  end
end

User Socket

# lib/myapp_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 verify_token(token) do
      {:ok, user_id} ->
        {:ok, assign(socket, :user_id, user_id)}
      {:error, _} ->
        :error
    end
  end

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

Client JavaScript

// assets/js/app.js
import {Socket} from "phoenix"

let socket = new Socket("/socket", {params: {token: userToken}})
socket.connect()

let channel = socket.channel("room:lobby", {})

channel.join()
  .receive("ok", resp => console.log("Joined successfully", resp))
  .receive("error", resp => console.log("Unable to join", resp))

channel.on("new_msg", payload => {
  console.log("New message:", payload.body)
})

document.querySelector("#send").addEventListener("click", e => {
  channel.push("new_msg", {body: inputValue})
})

Practical Examples

Authentication

# Context
defmodule Myapp.Accounts do
  def authenticate_user(email, password) do
    user = Repo.get_by(User, email: email)
    
    cond do
      user && Bcrypt.verify_pass(password, user.password_hash) ->
        {:ok, user}
      user ->
        {:error, :invalid_password}
      true ->
        Bcrypt.no_user_verify()
        {:error, :not_found}
    end
  end
end

# Controller
defmodule MyappWeb.SessionController do
  use MyappWeb, :controller

  def create(conn, %{"email" => email, "password" => password}) do
    case Accounts.authenticate_user(email, password) do
      {:ok, user} ->
        conn
        |> put_session(:user_id, user.id)
        |> put_flash(:info, "Welcome back!")
        |> redirect(to: "/")

      {:error, _} ->
        conn
        |> put_flash(:error, "Invalid credentials")
        |> render("new.html")
    end
  end

  def delete(conn, _params) do
    conn
    |> delete_session(:user_id)
    |> put_flash(:info, "Logged out")
    |> redirect(to: "/")
  end
end

File Upload

def create(conn, %{"post" => post_params}) do
  case post_params["image"] do
    %Plug.Upload{path: temp_path, filename: filename} ->
      destination = Path.join("priv/static/uploads", filename)
      File.cp!(temp_path, destination)
      
      post_params = Map.put(post_params, "image_url", "/uploads/#{filename}")
      # Create post with image_url
      
    _ ->
      # No file uploaded
  end
end

Pagination with Scrivener

# mix.exs
{:scrivener_ecto, "~> 2.7"}

# Context
def list_posts(params) do
  Post
  |> order_by(desc: :inserted_at)
  |> Repo.paginate(params)
end

# Controller
def index(conn, params) do
  page = Blog.list_posts(params)
  render(conn, "index.html", page: page)
end

# Template
<%= for post <- @page.entries do %>
  <!-- post content -->
<% end %>

<%= pagination_links @page %>

Testing

Controller Tests

defmodule MyappWeb.PostControllerTest do
  use MyappWeb.ConnCase

  test "lists all posts", %{conn: conn} do
    conn = get(conn, Routes.post_path(conn, :index))
    assert html_response(conn, 200) =~ "Posts"
  end

  test "creates post with valid data", %{conn: conn} do
    attrs = %{title: "Test", body: "Content"}
    conn = post(conn, Routes.post_path(conn, :create), post: attrs)
    
    assert redirected_to(conn) == Routes.post_path(conn, :index)
    assert get_flash(conn, :info) =~ "created"
  end
end

Channel Tests

defmodule MyappWeb.RoomChannelTest do
  use MyappWeb.ChannelCase

  test "broadcasts are pushed to clients", %{socket: socket} do
    {:ok, _, socket} = subscribe_and_join(socket, "room:lobby", %{})
    
    push(socket, "new_msg", %{body: "test"})
    assert_broadcast "new_msg", %{body: "test"}
  end
end

Exercises

  1. Create a blog CRUD application with posts and comments

  2. Add user authentication with sessions

  3. Build a real-time chat with Phoenix Channels

  4. Create a LiveView search interface with live results

  5. Add pagination to a list page

# Solutions

# 1. Blog CRUD (routes)
resources "/posts", PostController do
  resources "/comments", CommentController, only: [:create, :delete]
end

# 2. Authentication (session controller)
defmodule MyappWeb.SessionController do
  use MyappWeb, :controller
  alias Myapp.Accounts

  def new(conn, _params) do
    render(conn, "new.html")
  end

  def create(conn, %{"session" => %{"email" => email, "password" => password}}) do
    case Accounts.authenticate(email, password) do
      {:ok, user} ->
        conn
        |> put_session(:user_id, user.id)
        |> redirect(to: "/")
      {:error, _} ->
        conn
        |> put_flash(:error, "Invalid credentials")
        |> render("new.html")
    end
  end

  def delete(conn, _params) do
    conn
    |> clear_session()
    |> redirect(to: "/")
  end
end

# 3. Chat channel
defmodule MyappWeb.ChatChannel do
  use Phoenix.Channel

  def join("chat:" <> room_id, _params, socket) do
    {:ok, assign(socket, :room_id, room_id)}
  end

  def handle_in("new_message", %{"body" => body}, socket) do
    broadcast!(socket, "new_message", %{
      body: body,
      user: socket.assigns.username,
      timestamp: DateTime.utc_now()
    })
    {:noreply, socket}
  end
end

# 4. LiveView search
defmodule MyappWeb.SearchLive do
  use Phoenix.LiveView

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

  def handle_event("search", %{"query" => query}, socket) do
    results = if String.length(query) >= 2 do
      MyApp.search_products(query)
    else
      []
    end
    
    {:noreply, assign(socket, query: query, results: results)}
  end

  def render(assigns) do
    ~H"""
    <div>
      <form phx-change="search">
        <input type="text" name="query" value={@query} 
               placeholder="Search products..." />
      </form>
      
      <div>
        <%= for result <- @results do %>
          <div class="result">
            <h3><%= result.name %></h3>
            <p><%= result.description %></p>
          </div>
        <% end %>
      </div>
    </div>
    """
  end
end

# 5. Pagination
defmodule MyappWeb.PostController do
  def index(conn, params) do
    page = Blog.paginate_posts(params["page"] || "1")
    render(conn, "index.html", page: page)
  end
end

# Context
def paginate_posts(page_num) do
  page = String.to_integer(page_num)
  per_page = 20
  offset = (page - 1) * per_page

  posts = Post
  |> order_by(desc: :inserted_at)
  |> limit(^per_page)
  |> offset(^offset)
  |> Repo.all()

  total = Repo.aggregate(Post, :count)

  %{
    posts: posts,
    page: page,
    total_pages: ceil(total / per_page)
  }
end

Next Steps

Continue to 13-testing.md to learn about testing Elixir applications with ExUnit, doctests, and testing best practices.