Table of Contents 1. 2. 3. 4. 5. 6. 7. 8. 9. 10. 11. 12. 1. Introduction What Is Phoenix? Phoenix is a web application framework written in Elixir , a function…
Table of Contents
- Introduction
- Environment Setup
- Your First Phoenix App
- Routing
- Controllers and Views
- Contexts and Ecto
- Templates and the HEEx Component System
- Phoenix LiveView
- Worked Example: A Real-Time Task List
- Testing
- Deployment
- Next Steps and Further Resources
1. Introduction
What Is Phoenix?
Phoenix is a web application framework written in Elixir, a functional
language built on top of the BEAM — the Erlang virtual machine.
Phoenix was created by Chris McCord and first released in 2014, explicitly
inspired by Rails' developer-friendly conventions, but built from the
ground up to exploit what the BEAM is uniquely good at: massive
concurrency, fault tolerance, and low-latency real-time communication.
If you've used Rails or Django, a lot of Phoenix's shape will feel
familiar — routers, controllers, views, a database layer, generators, a
mix command line tool that mirrors rails or manage.py. The biggest
difference isn't structural, it's what the underlying platform makes
cheap. On the BEAM, spawning a lightweight process costs only a few
microseconds and a few kilobytes of memory, and the VM was designed from
day one (for 1980s telecom switches) to keep running even when parts of
the system crash. Phoenix inherits all of that, and it shows most clearly
in one specific feature: Phoenix LiveView, which we'll spend a full
chapter on later in this book.
Phoenix vs. Rails/Django: What's Actually Different
| Aspect |
Rails / Django |
Phoenix |
| Language paradigm |
Object-oriented |
Functional (immutable data, pattern matching) |
| Runtime |
MRI/CRuby or CPython (largely single-threaded per process, GIL in CPython) |
BEAM (true lightweight-process concurrency, no GIL) |
| Real-time / WebSockets |
Bolted on (ActionCable, Django Channels) |
First-class citizen (Channels, PubSub, Presence, LiveView) |
| "Model" layer |
ActiveRecord / Django ORM (objects that save themselves) |
Ecto (explicit, composable — data structures + functions, not "fat" model objects) |
| Fault tolerance |
Process-level (crash the whole request) |
Built on OTP supervision trees — isolated process crashes don't take down the app |
| Default interactivity story |
Full page reloads or hand-written JS/SPA |
LiveView: server-rendered, real-time DOM patches with minimal JS |
None of this makes Phoenix strictly "better" — it makes different
trade-offs. Phoenix's learning curve is steeper if you're new to
functional programming and immutability, since there are no mutable
objects quietly holding state for you. But that same explicitness is
exactly what makes Phoenix apps easier to reason about at scale, and what
makes LiveView possible without the whole architecture collapsing into
spaghetti.
A Word on the BEAM and OTP
You don't need to become an OTP expert to use Phoenix productively, but a
few concepts will keep surfacing, so let's name them up front:
- Process: In BEAM terms, a process is not an OS process — it's an
extremely lightweight, isolated unit of concurrency managed by the VM.
A single BEAM node can comfortably run millions of them. Each has its
own memory and communicates with others only via message passing.
- Supervisor: A process whose job is to watch other processes and
restart them if they crash, following a defined strategy. This is the
root of OTP's "let it crash" philosophy — instead of defensively coding
against every possible failure, you isolate failure to small units and
let a supervisor restart just that unit.
- GenServer: A standard behavior (pattern) for writing a
long-running, stateful process that responds to messages. Phoenix
channels and LiveView processes are, under the hood, built on similar
ideas.
You'll see these concepts in action later, especially in the LiveView
chapter, where each connected browser tab is backed by its own isolated
server-side process.
What This Book Assumes
This book assumes you're comfortable with core Elixir: modules, pattern
matching, the pipe operator (|>), Enum/Map/Struct basics, and
using mix. It does not assume prior Erlang or OTP knowledge, nor prior
web framework experience. By the end, you'll be able to build, test, and
deploy a real, real-time Phoenix application.
We'll target Phoenix 1.8 (the current stable release line, at
version 1.8.7 as of this writing), running on Elixir 1.15 or newer
(1.17+ recommended) and Erlang/OTP 26+.
2. Environment Setup
Installing Erlang/OTP and Elixir
Elixir runs on top of Erlang/OTP, so you need both. The most reliable way
to manage versions of each is a version manager rather than your OS
package manager, since Phoenix apps often pin specific version
combinations.
Recommended: asdf (works across macOS/Linux; use WSL2 on Windows)
# Install asdf, then add the plugins:
asdf plugin add erlang
asdf plugin add elixir
# Install specific versions:
asdf install erlang 27.1
asdf install elixir 1.18.1-otp-27
# Set them globally (or per-project with a .tool-versions file):
asdf global erlang 27.1
asdf global elixir 1.18.1-otp-27
Verify:
elixir -v
# => Erlang/OTP 27 ...
# => Elixir 1.18.1 (compiled with Erlang/OTP 27)
If you're on Windows, use WSL2 with an Ubuntu distribution and follow
the Linux instructions inside it — this avoids a long list of native
compilation issues that show up with native Windows Erlang installs.
Installing Hex and the Phoenix Application Generator
Hex is Elixir's package manager (like RubyGems or npm). Once Elixir is
installed:
mix local.hex --force
mix local.rebar --force
mix archive.install hex phx_new
That last command installs phx.new, the generator you'll use to
scaffold new Phoenix applications — conceptually identical to rails new
or django-admin startproject.
Installing PostgreSQL
Phoenix's default database adapter is Ecto with PostgreSQL, and unlike
Rails 8's shift toward SQLite-by-default, Phoenix has stuck with Postgres
as its conventional default because of how well it pairs with Ecto's
query composition and LISTEN/NOTIFY-based features some libraries build
on.
- macOS:
brew install postgresql@16 && brew services start postgresql@16
- Ubuntu/Debian (including WSL2):
sudo apt install postgresql postgresql-contrib
- Docker (an easy cross-platform alternative):
docker run --name phoenix-postgres -e POSTGRES_PASSWORD=postgres \
-p 5432:5432 -d postgres:16
Installing Node.js (Optional)
Modern Phoenix (1.7+) ships with esbuild and Tailwind CSS
installed automatically as standalone binaries via Elixir packages
(esbuild and tailwind hex packages) — no Node.js or npm required
for the default asset pipeline. You only need Node.js if you want to pull
in npm-only JavaScript packages beyond what import maps or esbuild's
built-in bundling can handle directly.
A Note on Editors
VS Code with the ElixirLS extension gives you autocomplete,
inline type/dialyzer warnings, and go-to-definition for both Elixir and
HEEx templates. The Zed editor also has strong out-of-the-box Elixir
support. Whichever you choose, make sure syntax highlighting for .heex
files is enabled — regular HTML highlighting will misparse Phoenix's HEEx
tags.
3. Your First Phoenix App
Generating a New Application
mix phx.new hello_web
cd hello_web
The generator will ask whether to fetch and install dependencies — say
yes. It will also print instructions for creating your database.
mix ecto.create
Understanding the Generated Structure
hello_web/
├── assets/ # CSS/JS source (esbuild + Tailwind input)
├── config/
│ ├── config.exs # Shared app configuration
│ ├── dev.exs # Dev-only config (DB credentials, etc.)
│ ├── prod.exs
│ └── runtime.exs # Runtime config, read from env vars
├── lib/
│ ├── hello_web/ # Web layer: everything HTTP-facing
│ │ ├── controllers/
│ │ ├── live/ # LiveView modules
│ │ ├── components/ # Reusable function components
│ │ ├── router.rb # (Note: it's router.ex — see below)
│ │ └── endpoint.ex # The entry point for all requests
│ └── hello_web.ex # Contains `use` macros shared across the web layer
│ └── hello/ # Business logic: contexts, Ecto schemas
│ └── application.ex # OTP application + supervision tree
├── priv/
│ └── repo/
│ └── migrations/ # Database migrations
├── test/
└── mix.exs # Project config + dependencies (like Gemfile)
The single most important structural idea to internalize here is the
split between lib/hello/ (your business logic — contexts, Ecto
schemas, plain Elixir) and lib/hello_web/ (everything that talks
HTTP — controllers, views, LiveViews, templates). This separation is
enforced by convention, not by the language, but Phoenix generators
follow it religiously, and you should too: your core business logic
should not need to know it's being served over the web at all.
Starting the Development Server
mix phx.server
Visit http://localhost:4000. You should see the Phoenix welcome page.
Phoenix also gives you an interactive Elixir shell with your application
loaded, similar to rails console:
iex -S mix phx.server
From here you can call functions in your app directly, inspect data, and
experiment — this becomes an essential habit for exploring Ecto queries
and context functions as you build.
The mix Command
Just as rails is Rails' command-line tool, mix is Elixir's — and
Phoenix adds a set of phx.* tasks to it:
mix phx.gen.context Blog Post posts title:string body:text # context + schema + migration
mix phx.gen.html Blog Post posts title:string body:text # + controller + HTML views
mix phx.gen.live Blog Post posts title:string body:text # + LiveView instead of controller
mix ecto.migrate # apply migrations
mix test # run tests
mix phx.routes # list all routes
phx.gen.live is worth calling out now, because it reflects how Phoenix
development has shifted since version 1.7: LiveView-first is now the
default recommendation for most application UIs, with traditional
controller+HTML generators (phx.gen.html) reserved for cases where you
specifically want plain request/response pages (e.g., a JSON API, or
pages with no interactivity).
4. Routing
The Router Module
Phoenix routes live in lib/hello_web/router.ex, a single Elixir module
using a small DSL:
defmodule HelloWeb.Router do
use HelloWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {HelloWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
end
end
Pipelines: Phoenix's Middleware Chain
A pipeline is a named sequence of plugs — small, composable units
of request/response transformation (a plug is conceptually similar to
Rack middleware in Rails, or Django middleware, but explicit and
composed per-route-scope rather than globally applied by default).
The :browser pipeline above sets up sessions, CSRF protection, and flash
messages — everything an HTML-serving route typically needs. You could
define a separate :api pipeline that skips all of that and just
negotiates JSON:
pipeline :api do
plug :accepts, ["json"]
end
scope "/api", HelloWeb do
pipe_through :api
resources "/posts", PostController, except: [:new, :edit]
end
Resourceful Routes
Like Rails, Phoenix has a resources macro that generates a full set of
RESTful routes from one line:
resources "/posts", PostController
| HTTP Verb |
Path |
Controller#Action |
| GET |
/posts |
PostController.index/2 |
| GET |
/posts/new |
PostController.new/2 |
| POST |
/posts |
PostController.create/2 |
| GET |
/posts/:id |
PostController.show/2 |
| GET |
/posts/:id/edit |
PostController.edit/2 |
| PATCH/PUT |
/posts/:id |
PostController.update/2 |
| DELETE |
/posts/:id |
PostController.delete/2 |
View this table for your actual app at any time:
mix phx.routes
LiveView Routes
LiveViews are wired into the router with live/3, not get:
scope "/", HelloWeb do
pipe_through :browser
live "/posts", PostLive.Index, :index
live "/posts/new", PostLive.Index, :new
live "/posts/:id/edit", PostLive.Index, :edit
live "/posts/:id", PostLive.Show, :show
end
Notice PostLive.Index handles three different "actions" (:index,
:new, :edit) rather than three separate controller actions. This is a
deliberate LiveView convention: a single LiveView process can render
different UI states (like showing a modal for "new" on top of the list)
without a full page navigation, and the third argument (:index,
:new, etc.) tells the handle_params/3 callback which state to render.
We'll unpack this fully in the LiveView chapter.
Scopes
Scopes group routes under a shared path prefix and/or module namespace:
scope "/admin", HelloWeb.Admin do
pipe_through [:browser, :require_admin]
resources "/posts", PostController
end
This nests PostController inside HelloWeb.Admin, and applies a custom
:require_admin plug (which you'd define yourself) in addition to the
standard :browser pipeline.
5. Controllers and Views
While LiveView is Phoenix's flagship feature, traditional
controller-and-view request handling is still fully supported and often
the right tool — for JSON APIs, simple static-ish pages, or file
downloads, for example.
Anatomy of a Controller
# lib/hello_web/controllers/post_controller.ex
defmodule HelloWeb.PostController do
use HelloWeb, :controller
alias Hello.Blog
def index(conn, _params) do
posts = Blog.list_posts()
render(conn, :index, posts: posts)
end
def show(conn, %{"id" => id}) do
post = Blog.get_post!(id)
render(conn, :show, post: post)
end
end
A few things worth noting, especially if you're coming from Rails:
conn (the "connection struct") is explicit and threaded through
every function — there's no implicit params/request/response
magic. Every plug and controller action receives conn and returns a
(possibly modified) conn. This is Elixir's immutability showing
through: nothing mutates a shared object behind your back.
- Pattern matching in function heads (
%{"id" => id}) replaces what
in Rails would be params[:id] — Phoenix controller actions commonly
destructure exactly the params they need right in the argument list.
render/3 doesn't render an ERB-equivalent file by filename
convention alone — it calls into a dedicated View module
(PostHTML by Phoenix 1.7+ convention) which itself contains or
delegates to template functions. We'll see this next.
Views in Modern Phoenix (Function Components)
Since Phoenix 1.7, the "view" layer is implemented as HEEx function
components rather than separate ERB-like template files being magically
matched by name (though template files are still supported and commonly
used together). A typical setup:
# lib/hello_web/controllers/post_html.ex
defmodule HelloWeb.PostHTML do
use HelloWeb, :html
embed_templates "post_html/*"
end
embed_templates pulls in .html.heex files from the post_html/
directory and turns each into a function matching its filename — so
index.html.heex becomes an index/1 function, callable as a normal
Elixir function and directly testable as one.
JSON Controllers
For an API, the pattern is nearly identical, just rendering JSON instead:
defmodule HelloWeb.Api.PostController do
use HelloWeb, :controller
alias Hello.Blog
def index(conn, _params) do
posts = Blog.list_posts()
render(conn, :index, posts: posts)
end
end
# lib/hello_web/controllers/api/post_json.ex
defmodule HelloWeb.Api.PostJSON do
def index(%{posts: posts}) do
%{data: for(post <- posts, do: data(post))}
end
defp data(post) do
%{id: post.id, title: post.title, body: post.body}
end
end
Again — no macro magic serializing your model automatically. You write an
explicit function that shapes the JSON output. This is more boilerplate
than, say, Rails' to_json, but it means you always know exactly what
your API returns and can never accidentally leak a field.
6. Contexts and Ecto
What's a "Context"?
Rails and Django both encourage putting business logic directly on model
classes (ActiveRecord callbacks, "fat models"). Phoenix takes a
different, more explicit approach: contexts.
A context is just a plain Elixir module that exposes a curated public API
for a specific area of your business domain (e.g., Blog, Accounts,
Billing). Controllers and LiveViews call into contexts; they never
query Ecto schemas directly. This keeps your web layer decoupled from
your persistence details and gives each domain area one clear entry
point.
# lib/hello/blog.ex
defmodule Hello.Blog do
alias Hello.Repo
alias Hello.Blog.Post
def list_posts do
Repo.all(Post)
end
def get_post!(id), do: Repo.get!(Post, id)
def create_post(attrs \\ %{}) do
%Post{}
|> Post.changeset(attrs)
|> Repo.insert()
end
def update_post(%Post{} = post, attrs) do
post
|> Post.changeset(attrs)
|> Repo.update()
end
def delete_post(%Post{} = post) do
Repo.delete(post)
end
end
Ecto Schemas
An Ecto schema maps a database table to an Elixir struct — similar in
spirit to an ActiveRecord model, but purely a data shape, with no
behavior of its own baked in:
# lib/hello/blog/post.ex
defmodule Hello.Blog.Post do
use Ecto.Schema
import Ecto.Changeset
schema "posts" do
field :title, :string
field :body, :string
field :published, :boolean, default: false
timestamps(type: :utc_datetime)
end
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :body, :published])
|> validate_required([:title, :body])
|> validate_length(:title, max: 200)
end
end
Migrations
Migrations look structurally similar to Rails', but are written as plain
Elixir modules using Ecto's DSL:
# priv/repo/migrations/20260115000000_create_posts.exs
defmodule Hello.Repo.Migrations.CreatePosts do
use Ecto.Migration
def change do
create table(:posts) do
add :title, :string
add :body, :text
add :published, :boolean, default: false
timestamps(type: :utc_datetime)
end
end
end
Run pending migrations:
mix ecto.migrate
Changesets: Phoenix's Answer to Validation
This is one of the most important concepts in the whole framework, and
where Ecto most clearly diverges from ActiveRecord/Django ORM. A
changeset is a data structure that represents a proposed change to
a schema, along with any validation errors — it is not the schema itself,
and creating one doesn't touch the database at all.
changeset = Post.changeset(%Post{}, %{title: "Hello", body: "World"})
changeset.valid? # => true
changeset.errors # => []
bad_changeset = Post.changeset(%Post{}, %{title: nil})
bad_changeset.valid? # => false
bad_changeset.errors # => [title: {"can't be blank", [...]}, body: {"can't be blank", [...]}]
Because a changeset is just data, you can inspect it, test it in
isolation with zero database access, pass it around, and combine multiple
validation steps as a pipeline — all before ever calling Repo.insert/1
or Repo.update/1 to actually persist anything. This explicitness is a
deliberate trade-off: more to write than an ActiveRecord validates
call, but validation logic becomes a pure, independently testable
function rather than something entangled with a live database-backed
object.
Ecto Queries
Ecto's query syntax is composable and pipe-friendly:
import Ecto.Query
def published_posts do
Post
|> where(published: true)
|> order_by(desc: :inserted_at)
|> Repo.all()
end
def search_posts(term) do
Post
|> where([p], ilike(p.title, ^"%#{term}%"))
|> Repo.all()
end
Notice queries build up as data (an Ecto.Query struct) before ever
touching the database — Repo.all/1, Repo.one/1, Repo.get/2, etc. are
what actually execute a query. This separation makes queries themselves
independently testable and composable, similar in spirit to how
ActiveRecord relations are lazy, but more explicit about the boundary
between "building a query" and "running it."
Associations
# lib/hello/blog/comment.ex
defmodule Hello.Blog.Comment do
use Ecto.Schema
import Ecto.Changeset
schema "comments" do
field :body, :string
belongs_to :post, Hello.Blog.Post
timestamps(type: :utc_datetime)
end
def changeset(comment, attrs) do
comment
|> cast(attrs, [:body, :post_id])
|> validate_required([:body, :post_id])
end
end
# In Post schema:
has_many :comments, Hello.Blog.Comment
post = Repo.get!(Post, 1) |> Repo.preload(:comments)
post.comments # loaded list of Comment structs
Repo.preload/2 is worth calling out: Ecto never lazily loads
associations behind the scenes the way ActiveRecord does by default.
You must explicitly preload what you need, which avoids the classic
"N+1 query" surprise entirely — if you forgot to preload, you get a
clear error rather than silent per-row queries.
7. Templates and the HEEx Component System
What Is HEEx?
HEEx (HTML + EEx, Elixir's built-in templating engine) is Phoenix's
templating language. It looks like ERB or JSX in spirit — HTML with
embedded Elixir expressions — but it's compiled at build time with strict
HTML validation and change tracking baked in (essential for LiveView's
efficient diffing, covered next chapter).
<h1>All Posts</h1>
<%= if @posts == [] do %>
<p>No posts yet.</p>
<% else %>
<ul>
<li :for={post <- @posts}>
<%= post.title %>
</li>
</ul>
<% end %>
A few syntax notes:
<%= expr %> outputs a value, just like ERB.
<% expr %> runs code without output (control flow).
:for and :if are special HEEx attributes for inline loops
and conditionals directly on an HTML tag — often cleaner than wrapping
a whole block:
<li :for={post <- @posts} :if={post.published}>
<%= post.title %>
</li>
Function Components
Phoenix strongly favors function components — plain Elixir functions
that take assigns and return HEEx — over partial template files, for
reusable UI pieces:
# lib/hello_web/components/core_components.ex (generated by default)
attr :post, Hello.Blog.Post, required: true
def post_card(assigns) do
~H"""
<article class="rounded border p-4">
<h2 class="text-xl font-bold"><%= @post.title %></h2>
<p><%= @post.body %></p>
</article>
"""
end
Used like this:
<.post_card :for={post <- @posts} post={post} />
The attr declaration above isn't decorative — it's checked at compile
time, so passing the wrong type or forgetting a required attribute
produces a compile warning, not a runtime surprise. This is a level of
safety templating layers in Rails or Django simply don't offer, and it
comes directly from Elixir being a compiled language with a macro system
expressive enough to build this kind of check into ~H""" sigils.
Layouts
Like Rails, Phoenix wraps pages in a root layout, generated by default at
lib/hello_web/components/layouts/root.html.heex, which includes a
{@inner_content} (for controller-rendered pages) — LiveView pages get
their own nested app layout automatically applied on top of it.
Tailwind CSS by Default
Since Phoenix 1.7, new applications are generated with Tailwind CSS
pre-configured (no separate install step), installed as a standalone
binary rather than requiring Node.js. You'll see utility classes like
class="rounded border p-4" throughout generated code and this book's
examples for exactly that reason.
8. Phoenix LiveView
This is Phoenix's signature feature, and the reason many teams choose it
over Rails or Django in the first place: real-time, stateful,
server-rendered UI with minimal hand-written JavaScript.
The Problem LiveView Solves
Traditionally, you have two options for interactive UI:
- Full page reloads (classic server-rendered apps) — simple, but
feels dated; every interaction requires a round trip and a full
re-render.
- A JavaScript SPA (React, Vue, etc.) talking to a JSON API — smooth
UX, but you're now maintaining two applications (frontend + backend),
duplicating validation and business logic, and dealing with client-side
state management complexity.
LiveView offers a third path: the server keeps a live, stateful
Elixir process for each connected client, over a WebSocket connection. All
your logic — including UI state — lives in Elixir on the server. When
state changes, Phoenix computes a minimal HTML diff and pushes just that
diff down to the browser, which a small, generic JavaScript library
(phoenix_live_view.js, already included) applies to the DOM. You write
almost no JavaScript, yet the UX feels like a modern SPA.
This is only practical because of the BEAM: each connected LiveView
becomes its own lightweight, isolated process, so an app with 50,000
concurrently connected users means 50,000 small processes, each with its
own small chunk of memory — genuinely cheap at this scale in a way that
would be difficult to replicate on most other server platforms.
Anatomy of a LiveView
# lib/hello_web/live/counter_live.ex
defmodule HelloWeb.CounterLive do
use HelloWeb, :live_view
def mount(_params, _session, socket) do
{:ok, assign(socket, count: 0)}
end
def handle_event("increment", _params, socket) do
{:noreply, assign(socket, count: socket.assigns.count + 1)}
end
def render(assigns) do
~H"""
<div>
<p>Count: <%= @count %></p>
<button phx-click="increment">+1</button>
</div>
"""
end
end
Wired into the router:
live "/counter", CounterLive
Walking through the lifecycle:
mount/3 runs when a client first connects (actually twice —
once for the initial HTTP request render, once again when the
WebSocket connects — Phoenix handles this transparently for you). It
sets up initial state via assign/3, similar in spirit to setting
instance variables in a Rails controller action.
render/1 is called any time assigns change, and returns HEEx.
You never call this manually — Phoenix schedules re-renders for you.
phx-click="increment" is a HEEx binding that, on click, sends an
"increment" event over the already-open WebSocket to the server —
no page navigation, no manually-written fetch() call.
handle_event/3 receives that event, updates state via
assign/3, and returns {:noreply, socket}. Phoenix then
automatically re-renders and sends only the changed HTML down the
wire.
assign/3 and Immutability
socket is, like conn, an immutable struct. assign/3 doesn't mutate
anything — it returns a new socket with updated assigns. This is why
every handle_event callback ends by returning an updated socket; if
you forget, your state simply won't change, and there's no ambiguity
about where state lives or who can silently mutate it.
Real-Time Updates via PubSub
LiveView's other superpower is trivially broadcasting updates to other
connected clients — not just responding to the client that triggered an
event. Phoenix ships with Phoenix.PubSub built in:
def mount(_params, _session, socket) do
if connected?(socket), do: Phoenix.PubSub.subscribe(Hello.PubSub, "posts")
{:ok, assign(socket, posts: Blog.list_posts())}
end
def handle_info({:new_post, post}, socket) do
{:noreply, update(socket, :posts, &[post | &1])}
end
Elsewhere (e.g., in a context function after creating a post):
def create_post(attrs) do
with {:ok, post} <- do_create_post(attrs) do
Phoenix.PubSub.broadcast(Hello.PubSub, "posts", {:new_post, post})
{:ok, post}
end
end
Now, when any user creates a post, every browser tab currently viewing
the posts list updates instantly, with no polling and no custom
WebSocket wiring on your part. This pattern — subscribe in mount,
broadcast from your context, handle in handle_info — is the backbone of
almost every real-time feature you'll build in Phoenix: live comment
threads, collaborative editing indicators, live dashboards, chat, and
more.
Forms in LiveView
LiveView forms use a Phoenix.Component.to_form/2 wrapper around a
changeset, giving you real-time validation feedback without a page
reload:
def mount(_params, _session, socket) do
changeset = Blog.change_post(%Post{})
{:ok, assign(socket, form: to_form(changeset))}
end
def handle_event("validate", %{"post" => post_params}, socket) do
changeset =
%Post{}
|> Blog.change_post(post_params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(changeset))}
end
def handle_event("save", %{"post" => post_params}, socket) do
case Blog.create_post(post_params) do
{:ok, _post} ->
{:noreply, put_flash(socket, :info, "Post created!")}
{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
<.form for={@form} phx-change="validate" phx-submit="save">
<.input field={@form[:title]} label="Title" />
<.input field={@form[:body]} type="textarea" label="Body" />
<.button>Save</.button>
</.form>
phx-change="validate" fires on every keystroke/change, giving the user
live validation feedback as they type — entirely driven by your existing
Ecto changeset logic, with zero duplicated client-side validation code.
LiveComponents vs. Function Components
For most reuse, plain function components (Chapter 7) are enough. But
when a piece of UI needs its own isolated state and event handling
within a page (e.g., an inline-editable item in a list), Phoenix offers
Phoenix.LiveComponent — a stateful component with its own
mount/update/handle_event callbacks, rendered inside a parent LiveView.
Reach for these only when a function component genuinely isn't enough;
they add complexity that isn't always warranted.
9. Worked Example: A Real-Time Task List
Let's build a small end-to-end app that ties together contexts, Ecto,
LiveView, and PubSub — a task list where every connected browser sees
updates from every other browser instantly, with no manual refresh.
Step 1: Generate the App
mix phx.new tasklist
cd tasklist
mix ecto.create
Step 2: Generate the Context, Schema, and LiveView
Phoenix's generators can scaffold a huge amount of this for us in one
command:
mix phx.gen.live Tasks Task tasks title:string done:boolean
This generates:
lib/tasklist/tasks.ex — the Tasks context
lib/tasklist/tasks/task.ex — the Task Ecto schema
priv/repo/migrations/..._create_tasks.exs — the migration
lib/tasklist_web/live/task_live/ — Index, Show, and Form
LiveView modules, fully wired for CRUD
The generator prints instructions to add routes — follow them:
# lib/tasklist_web/router.ex
live "/tasks", TaskLive.Index, :index
live "/tasks/new", TaskLive.Index, :new
live "/tasks/:id/edit", TaskLive.Index, :edit
live "/tasks/:id", TaskLive.Show, :show
live "/tasks/:id/show/edit", TaskLive.Show, :edit
mix ecto.migrate
At this point, mix phx.server and visiting /tasks already gives you a
fully working CRUD app with live-updating forms and validation — but
it doesn't yet broadcast changes between different browser tabs. Let's
add that.
Step 3: Add PubSub Broadcasting to the Context
# lib/tasklist/tasks.ex
defmodule Tasklist.Tasks do
alias Tasklist.Repo
alias Tasklist.Tasks.Task
alias Phoenix.PubSub
@topic "tasks"
def subscribe, do: PubSub.subscribe(Tasklist.PubSub, @topic)
def list_tasks, do: Repo.all(Task)
def create_task(attrs \\ %{}) do
%Task{}
|> Task.changeset(attrs)
|> Repo.insert()
|> broadcast(:task_created)
end
def update_task(%Task{} = task, attrs) do
task
|> Task.changeset(attrs)
|> Repo.update()
|> broadcast(:task_updated)
end
def delete_task(%Task{} = task) do
Repo.delete(task)
|> broadcast(:task_deleted)
end
defp broadcast({:ok, task}, event) do
PubSub.broadcast(Tasklist.PubSub, @topic, {event, task})
{:ok, task}
end
defp broadcast(error, _event), do: error
end
Step 4: Subscribe and Handle Broadcasts in the LiveView
# lib/tasklist_web/live/task_live/index.ex
defmodule TasklistWeb.TaskLive.Index do
use TasklistWeb, :live_view
alias Tasklist.Tasks
def mount(_params, _session, socket) do
if connected?(socket), do: Tasks.subscribe()
{:ok, assign(socket, :tasks, Tasks.list_tasks())}
end
def handle_info({:task_created, task}, socket) do
{:noreply, update(socket, :tasks, &[task | &1])}
end
def handle_info({:task_updated, updated}, socket) do
tasks = Enum.map(socket.assigns.tasks, fn
%{id: id} when id == updated.id -> updated
task -> task
end)
{:noreply, assign(socket, :tasks, tasks)}
end
def handle_info({:task_deleted, deleted}, socket) do
tasks = Enum.reject(socket.assigns.tasks, &(&1.id == deleted.id))
{:noreply, assign(socket, :tasks, tasks)}
end
# ... existing generated CRUD handle_event callbacks remain unchanged
end
Step 5: Try It
mix phx.server
Open http://localhost:4000/tasks in two different browser windows
side by side. Create, edit, or delete a task in one window — watch it
appear instantly in the other, with no refresh and no polling. This is
the full LiveView + PubSub loop working end to end, and it took roughly
sixty lines of Elixir beyond what the generator gave us for free.
10. Testing
ExUnit: Elixir's Built-in Test Framework
Unlike Rails (which can use either Minitest or RSpec), the Elixir
ecosystem has effectively standardized on ExUnit, which ships with
Elixir itself — there's no meaningful "which testing framework" decision
to make.
# test/tasklist/tasks_test.exs
defmodule Tasklist.TasksTest do
use Tasklist.DataCase
alias Tasklist.Tasks
describe "tasks" do
test "create_task/1 with valid data creates a task" do
assert {:ok, task} = Tasks.create_task(%{title: "Buy milk"})
assert task.title == "Buy milk"
end
test "create_task/1 with invalid data returns error changeset" do
assert {:error, changeset} = Tasks.create_task(%{title: nil})
assert "can't be blank" in errors_on(changeset).title
end
end
end
Run the suite:
mix test
Tasklist.DataCase is a generated test case template that wraps each
test in a database transaction (via Ecto's Sandbox), automatically
rolled back after the test — so tests never leak data into each other,
without you writing any manual cleanup code.
Testing Controllers
Generated controller tests use Tasklist.ConnCase, which wraps
Plug.Test helpers for simulating requests:
test "GET /posts", %{conn: conn} do
conn = get(conn, ~p"/posts")
assert html_response(conn, 200) =~ "Listing Posts"
end
The ~p"/posts" sigil is Phoenix's verified routes feature — it
checks at compile time that /posts actually exists as a defined
route, catching typos and stale links immediately rather than as a
runtime 404 you might not notice until production.
Testing LiveViews
Phoenix ships Phoenix.LiveViewTest, which lets you simulate a full
LiveView interaction — connecting, clicking, submitting forms — without a
real browser:
# test/tasklist_web/live/task_live_test.exs
defmodule TasklistWeb.TaskLiveTest do
use TasklistWeb.ConnCase
import Phoenix.LiveViewTest
test "creates a task", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/tasks")
view
|> form("#task-form", task: %{title: "Walk the dog"})
|> render_submit()
assert render(view) =~ "Walk the dog"
end
end
This test drives the entire LiveView lifecycle — mount, handle_event,
re-render — in-process, without spinning up a browser or a WebSocket
connection, making it both fast and reliable. For true end-to-end browser
testing (e.g., verifying real JavaScript interactions), tools like
Wallaby or PhoenixTest (which layers browser-driving on top of
LiveViewTest) are common additions.
11. Deployment
mix release: Phoenix's Built-in Release Tool
Elixir's mix release (built into Elixir itself, not a separate gem)
packages your application, all its dependencies, and even the Erlang
runtime itself into a single self-contained artifact that doesn't require
Elixir or Erlang to be installed on the target machine at all:
MIX_ENV=prod mix release
The output is a directory (or you can build a Docker image around it)
containing everything needed to run your app with a single command like
_build/prod/rel/tasklist/bin/tasklist start.
Deploying with a Dockerfile
Newly generated Phoenix apps include a production-ready Dockerfile by
default (via mix phx.gen.release --docker, or automatically depending on
generator version), built around a multi-stage build that compiles a
release in a builder image and copies just the release artifact into a
slim runtime image.
mix phx.gen.release --docker
docker build -t tasklist .
Fly.io
Fly.io has particularly strong, close-to-first-party Phoenix support
(the Phoenix and Fly.io teams have collaborated directly on tooling), and
is a common default recommendation for deploying Phoenix apps without
managing your own servers:
fly launch # detects the Dockerfile, provisions a Postgres cluster, generates fly.toml
fly deploy # subsequent deploys
fly launch will offer to provision a managed Postgres cluster and wire
DATABASE_URL automatically, which pairs neatly with Ecto's runtime
configuration in config/runtime.exs.
Other Deployment Options
- Gigalixir — a PaaS built specifically for Elixir/Phoenix, with
first-class support for hot code upgrades and distributed clustering.
- Render — general-purpose PaaS with solid Docker + Postgres support,
similar workflow to deploying a Rails app there.
- Self-managed VPS + systemd — since a
mix release is just a
self-contained artifact, you can run it directly on any Linux server
behind a reverse proxy (nginx or Caddy) with a systemd unit managing
the process — no container required if you'd rather not use Docker.
Clustering: A Uniquely Phoenix/BEAM Deployment Option
Because BEAM nodes can transparently connect and communicate with each
other, Phoenix apps deployed across multiple servers can form a
distributed cluster, letting PubSub broadcasts (and therefore
LiveView real-time updates) work correctly across every node — not just
within a single server process. Libraries like libcluster automate node
discovery on platforms like Fly.io or Kubernetes. This is worth knowing
about early, even if you don't need it on day one: it's the mechanism
that lets LiveView's real-time features scale horizontally without extra
infrastructure like a separate Redis pub/sub layer, which is commonly
needed for the equivalent in other frameworks.
12. Next Steps and Further Resources
You now understand Phoenix's core shape: the BEAM/OTP foundation and why
it matters, resourceful and LiveView routing, controllers and function
components, contexts and Ecto's explicit changeset-driven approach to
data, and — the centerpiece — LiveView's real-time, low-JavaScript UI
model backed by PubSub.
Where to go from here:
- Official Phoenix Guides (hexdocs.pm/phoenix) — the best first-party
reference, and kept current with each release.
- HexDocs for Ecto (hexdocs.pm/ecto) — essential once you go beyond
basic CRUD queries into joins, subqueries, and Ecto.Multi for
transactional multi-step operations.
- Elixir's official Getting Started guide — worth revisiting if any
core language concepts (pattern matching, processes, GenServer) still
feel shaky; Phoenix leans on all of them.
- Phoenix.Presence — for tracking which users are currently online /
viewing a given page in real time, built on the same PubSub foundation
as the broadcasting pattern in Chapter 9.
- Oban — the de facto standard background job library for Elixir
(conceptually similar to Sidekiq or ActiveJob), commonly reached for
once you need reliable async work beyond what a lightweight Task can
offer.
- LiveView Native — an emerging project for building native iOS/
Android UIs driven by the same LiveView server-side model, worth
knowing exists even if it's not needed for a first project.
- The Phoenix and Elixir source code — both are widely regarded as
clean, readable codebases; reading Phoenix.LiveView's source once
you're comfortable with the basics demystifies a lot of what feels like
"magic" early on.
Phoenix's steepest learning curve is genuinely at the very start — getting
comfortable with immutability, explicit data flow through conn/
socket, and pattern matching if you're coming from an object-oriented
background. Once that clicks, the framework gets out of your way
remarkably quickly, and LiveView in particular tends to make previously
JavaScript-heavy features feel almost suspiciously simple to build. The
best next step, as with any framework, is the same one that closed the
Rails book: pick a small real-time idea and build it, leaning on the
official guides whenever this book didn't go deep enough.
This book targets Phoenix 1.8.7, the current stable release as of
writing, running on Elixir 1.15+ (1.17+ recommended) and Erlang/OTP 26+.
Phoenix's release cadence is deliberate rather than rapid — expect the
core concepts covered here (contexts, changesets, LiveView's mount/
render/handle_event lifecycle, PubSub) to remain the right foundation for
future 1.x releases.