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
Create a blog CRUD application with posts and comments
Add user authentication with sessions
Build a real-time chat with Phoenix Channels
Create a LiveView search interface with live results
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.