Overview
Phoenix is a productive web framework for Elixir that implements the server-side MVC pattern. Built on top of the Erlang VM’s concurrency model, Phoenix handles millions of simultaneous connections efficiently, making it ideal for real-time applications. It provides a robust set of tools including LiveView for interactive UIs without JavaScript, Channels for WebSocket communication, and tight Ecto integration for database operations.
Phoenix was created by Chris McCord and has grown into one of the most performant web frameworks available. Its architecture leverages OTP (Open Telecom Platform) patterns for fault tolerance, and the Plug middleware pipeline for composable request handling. Phoenix LiveView, introduced in 2018, revolutionized the framework by enabling rich, real-time user experiences rendered entirely on the server.
Installation
Prerequisites and Setup
# Install Elixir (includes Mix)
brew install elixir # macOS
sudo apt install elixir # Ubuntu
# Install Hex package manager
mix local.hex
# Install Phoenix project 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 project
mix phx.new my_api --no-html --no-assets
# Setup and run
cd my_app
mix setup
mix phx.server
Project Structure
| Path | Description |
|---|
lib/my_app/ | Business logic (contexts, schemas) |
lib/my_app_web/ | Web layer (controllers, views, templates) |
lib/my_app_web/router.ex | Route definitions |
lib/my_app_web/endpoint.ex | HTTP endpoint configuration |
lib/my_app_web/controllers/ | Controller modules |
lib/my_app_web/live/ | LiveView modules |
lib/my_app_web/components/ | Function components |
priv/repo/migrations/ | Ecto database migrations |
priv/static/ | Static assets |
config/ | Configuration files |
test/ | Test files |
Routing
Router Configuration
# lib/my_app_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, 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
resources "/users", UserController
live "/dashboard", DashboardLive
end
scope "/api", MyAppWeb.API do
pipe_through :api
resources "/posts", PostController, except: [:new, :edit]
end
end
Route Helpers
| Route Macro | HTTP Method | Path | Controller Action |
|---|
get | GET | /users | :index |
get | GET | /users/:id | :show |
get | GET | /users/new | :new |
post | POST | /users | :create |
get | GET | /users/:id/edit | :edit |
put/patch | PUT/PATCH | /users/:id | :update |
delete | DELETE | /users/:id | :delete |
Controllers
defmodule MyAppWeb.UserController do
use MyAppWeb, :controller
alias MyApp.Accounts
def index(conn, _params) do
users = Accounts.list_users()
render(conn, :index, users: users)
end
def create(conn, %{"user" => user_params}) do
case Accounts.create_user(user_params) do
{:ok, user} ->
conn
|> put_flash(:info, "User created.")
|> redirect(to: ~p"/users/#{user}")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :new, changeset: changeset)
end
end
def show(conn, %{"id" => id}) do
user = Accounts.get_user!(id)
render(conn, :show, user: user)
end
end
LiveView
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>
<h1>Count: {@count}</h1>
<button phx-click="decrement">-</button>
<button phx-click="increment">+</button>
</div>
"""
end
end
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{}
|> Accounts.change_user(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!")
|> push_navigate(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 type="submit">Save</button>
</.form>
"""
end
end
Ecto Integration
Schema and Changeset
defmodule MyApp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :name, :string
field :email, :string
field :age, :integer
has_many :posts, MyApp.Blog.Post
timestamps()
end
def changeset(user, attrs) do
user
|> cast(attrs, [:name, :email, :age])
|> validate_required([:name, :email])
|> validate_format(:email, ~r/@/)
|> validate_number(:age, greater_than: 0)
|> unique_constraint(:email)
end
end
Context Functions
defmodule MyApp.Accounts do
import Ecto.Query
alias MyApp.Repo
alias MyApp.Accounts.User
def list_users do
Repo.all(User)
end
def get_user!(id), do: Repo.get!(User, id)
def create_user(attrs) do
%User{}
|> User.changeset(attrs)
|> Repo.insert()
end
def list_users_by_age(min_age) do
from(u in User, where: u.age >= ^min_age, order_by: [desc: u.age])
|> Repo.all()
end
end
Migrations
# Generate migration
mix ecto.gen.migration create_users
# Run migrations
mix ecto.migrate
# Rollback last migration
mix ecto.rollback
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
timestamps()
end
create unique_index(:users, [:email])
end
end
Configuration
Endpoint Configuration
# config/dev.exs
config :my_app, MyAppWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4000],
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: "dev-secret-key...",
watchers: [
esbuild: {Esbuild, :install_and_run, [:my_app, ~w(--sourcemap=inline --watch)]},
tailwind: {Tailwind, :install_and_run, [:my_app, ~w(--watch)]}
]
Production Release
# config/runtime.exs
import Config
if config_env() == :prod do
config :my_app, MyAppWeb.Endpoint,
url: [host: System.get_env("PHX_HOST"), port: 443, scheme: "https"],
http: [port: String.to_integer(System.get_env("PORT") || "4000")],
secret_key_base: System.fetch_env!("SECRET_KEY_BASE")
end
Advanced Usage
Channels (WebSockets)
# Channel definition
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})
{:noreply, socket}
end
end
PubSub
# Subscribe to topic
Phoenix.PubSub.subscribe(MyApp.PubSub, "notifications:#{user_id}")
# Broadcast to topic
Phoenix.PubSub.broadcast(MyApp.PubSub, "notifications:#{user_id}", {:new_notification, data})
# Handle in LiveView
def handle_info({:new_notification, data}, socket) do
{:noreply, assign(socket, notification: data)}
end
Mix Tasks
| Command | Description |
|---|
mix phx.gen.html Accounts User users name:string email:string | Generate HTML resource |
mix phx.gen.json API Post posts title:string body:text | Generate JSON API resource |
mix phx.gen.live Blog Post posts title:string | Generate LiveView resource |
mix phx.gen.auth Accounts User users | Generate authentication system |
mix phx.gen.context Blog Post posts title:string | Generate context only |
mix phx.gen.schema Blog.Post posts title:string | Generate schema only |
mix phx.routes | List all routes |
mix phx.digest | Compress and tag static assets |
Troubleshooting
| Problem | Solution |
|---|
| Port 4000 already in use | Kill existing process: lsof -i :4000 then kill <pid> |
| LiveView not connecting | Check WebSocket endpoint in endpoint.ex |
| Assets not compiling | Run mix assets.setup then mix assets.deploy |
| Migration errors | Check mix ecto.migrations for pending; run mix ecto.reset in dev |
| CSRF token errors | Ensure protect_from_forgery plug is in pipeline |
(Ecto.NoResultsError) | Use Repo.get/2 (returns nil) instead of Repo.get!/2 |
| PubSub messages not received | Verify subscription topic matches broadcast topic exactly |
| Deployment crashes | Check runtime.exs env vars are set; run mix release with MIX_ENV=prod |