Quick Start
Ready to learn Phoenix Framework quickly? This quick start tutorial provides a fast-paced tour through Phoenix’s core concepts. By the end, you’ll understand the fundamentals and be ready to build real web applications.
This tutorial provides 5-30% coverage - touching all essential concepts without deep dives. For 0-5% coverage (installation only), see Initial Setup. For deep dives, continue to Beginner Tutorial (0-60% coverage).
Prerequisites
Before starting this tutorial, you need:
- Phoenix Framework installed (see Initial Setup)
- Elixir 1.14+ and Erlang/OTP 24+
- PostgreSQL database running
- Basic Elixir knowledge (see Elixir Quick Start)
- Understanding of HTML and HTTP basics
- Text editor with Elixir support
- 90-120 minutes of focused learning
Learning Objectives
By the end of this tutorial, you will understand:
- Request Lifecycle - How Phoenix processes HTTP requests
- Routing - Mapping URLs to controller actions
- Controllers - Handling requests and returning responses
- Views and Templates - Rendering HTML with HEEx
- Layouts - Shared page structure
- Ecto Basics - Database interaction with schemas and queries
- Changesets - Data validation and casting
- Contexts - Organizing business logic
- LiveView Fundamentals - Real-time interactivity without JavaScript
- Channels - WebSocket communication
- Authentication - User login with phx.gen.auth
- Deployment - Releasing to production
Learning Path
%%{init: {'theme':'base', 'themeVariables': { 'primaryColor':'#0ea5e9','primaryTextColor':'#1e293b','primaryBorderColor':'#0369a1','lineColor':'#64748b','secondaryColor':'#f97316','tertiaryColor':'#8b5cf6','background':'#ffffff','mainBkg':'#f1f5f9','secondBkg':'#e2e8f0'}}}%%
graph TB
Start[Quick Start] --> Setup[Prerequisites Check]
Setup --> Lifecycle[Request Lifecycle]
Lifecycle --> Route[Routing]
Route --> Control[Controllers]
Control --> View[Views & Templates]
View --> Layout[Layouts]
Layout --> Ecto[Ecto Basics]
Ecto --> Change[Changesets]
Change --> Context[Contexts]
Context --> Live[LiveView]
Live --> Chan[Channels]
Chan --> Auth[Authentication]
Auth --> Deploy[Deployment]
Deploy --> Next[Next Steps]
style Start fill:#0ea5e9,stroke:#0369a1,stroke-width:3px,color:#ffffff
style Next fill:#10b981,stroke:#059669,stroke-width:3px,color:#ffffff
style Route fill:#f97316,stroke:#c2410c,stroke-width:2px,color:#ffffff
style Live fill:#8b5cf6,stroke:#6d28d9,stroke-width:2px,color:#ffffff
Phoenix Request Lifecycle
Understanding how Phoenix processes requests is fundamental.
The Full Journey
%%{init: {'theme':'base', 'themeVariables': { 'primaryColor':'#0ea5e9','primaryTextColor':'#1e293b','primaryBorderColor':'#0369a1','lineColor':'#64748b','secondaryColor':'#f97316','tertiaryColor':'#8b5cf6','background':'#ffffff','mainBkg':'#f1f5f9','secondBkg':'#e2e8f0'}}}%%
graph TD
Browser[Browser] --> Endpoint[Endpoint]
Endpoint --> Router[Router]
Router --> Pipeline[Pipeline/Plugs]
Pipeline --> Controller[Controller]
Controller --> View[View]
View --> Template[Template]
Template --> Response[HTTP Response]
Response --> Browser
style Endpoint fill:#0ea5e9,stroke:#0369a1,stroke-width:2px,color:#ffffff
style Controller fill:#f97316,stroke:#c2410c,stroke-width:2px,color:#ffffff
style Template fill:#8b5cf6,stroke:#6d28d9,stroke-width:2px,color:#ffffff
Key components:
- Endpoint - HTTP entry point
- Router - URL pattern matching
- Pipeline - Plug middleware chain
- Controller - Request handling logic
- View - Rendering preparation
- Template - HTML generation
Routing Fundamentals
Routes map HTTP verbs and paths to controller actions.
Basic Routes
Create new Phoenix project:
mix phx.new blog
cd blog
mix ecto.createOpen lib/blog_web/router.ex:
defmodule BlogWeb.Router do
use BlogWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {BlogWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", BlogWeb do
pipe_through :browser
get "/", PageController, :home
end
endRoute Patterns
scope "/", BlogWeb do
pipe_through :browser
# Static route
get "/", PageController, :home
# Route with parameter
get "/posts/:id", PostController, :show
# Multiple HTTP verbs
get "/posts/new", PostController, :new
post "/posts", PostController, :create
get "/posts/:id/edit", PostController, :edit
put "/posts/:id", PostController, :update
delete "/posts/:id", PostController, :delete
endResource Routes
Phoenix provides resources macro for RESTful routes:
scope "/", BlogWeb do
pipe_through :browser
get "/", PageController, :home
resources "/posts", PostController
endThis generates:
| HTTP Verb | Path | Controller Action | Purpose |
|---|---|---|---|
| GET | /posts | :index | List all posts |
| GET | /posts/:id | :show | Show single post |
| GET | /posts/new | :new | Show creation form |
| POST | /posts | :create | Create new post |
| GET | /posts/:id/edit | :edit | Show edit form |
| PUT/PATCH | /posts/:id | :update | Update post |
| DELETE | /posts/:id | :delete | Delete post |
List Routes
mix phx.routesOutput:
GET / BlogWeb.PageController :home
GET /posts BlogWeb.PostController :index
GET /posts/:id BlogWeb.PostController :show
GET /posts/new BlogWeb.PostController :new
POST /posts BlogWeb.PostController :create
GET /posts/:id/edit BlogWeb.PostController :edit
PATCH/PUT /posts/:id BlogWeb.PostController :update
DELETE /posts/:id BlogWeb.PostController :deleteControllers - Request Handling
Controllers receive requests and return responses.
Generate Controller
mix phx.gen.html Blog Post posts title:string body:text published:booleanThis generates:
- Migration file
- Schema (Ecto model)
- Context module
- Controller
- Views and templates
- Tests
Update router as instructed:
scope "/", BlogWeb do
pipe_through :browser
get "/", PageController, :home
resources "/posts", PostController
endRun migration:
mix ecto.migrateController Actions
Generated controller at lib/blog_web/controllers/post_controller.ex:
defmodule BlogWeb.PostController do
use BlogWeb, :controller
alias Blog.Blog
alias Blog.Blog.Post
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
def new(conn, _params) do
changeset = Blog.change_post(%Post{})
render(conn, :new, changeset: changeset)
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: ~p"/posts/#{post}")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :new, changeset: changeset)
end
end
def edit(conn, %{"id" => id}) do
post = Blog.get_post!(id)
changeset = Blog.change_post(post)
render(conn, :edit, post: post, changeset: changeset)
end
def update(conn, %{"id" => id, "post" => post_params}) do
post = Blog.get_post!(id)
case Blog.update_post(post, post_params) do
{:ok, post} ->
conn
|> put_flash(:info, "Post updated successfully.")
|> redirect(to: ~p"/posts/#{post}")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :edit, post: post, changeset: changeset)
end
end
def delete(conn, %{"id" => id}) do
post = Blog.get_post!(id)
{:ok, _post} = Blog.delete_post(post)
conn
|> put_flash(:info, "Post deleted successfully.")
|> redirect(to: ~p"/posts")
end
endConn Struct
conn is the connection struct containing request/response data:
def show(conn, %{"id" => id}) do
# conn fields:
# conn.assigns - Data for templates
# conn.params - Request parameters
# conn.req_headers - Request headers
# conn.method - HTTP method (GET, POST, etc.)
# conn.path_info - Path segments
IO.inspect(conn.method) # => "GET"
IO.inspect(conn.path_info) # => ["posts", "1"]
post = Blog.get_post!(id)
render(conn, :show, post: post)
endController Helpers
Flash messages:
conn
|> put_flash(:info, "Success!")
|> put_flash(:error, "Failed!")Redirects:
redirect(conn, to: ~p"/posts")
redirect(conn, to: ~p"/posts/#{post}")
redirect(conn, external: "https://example.com")JSON responses:
def show(conn, %{"id" => id}) do
post = Blog.get_post!(id)
json(conn, %{id: post.id, title: post.title})
endStatus codes:
conn
|> put_status(:not_found)
|> render(:error, message: "Not found")Views and Templates
Views prepare data for templates, templates generate HTML.
View Module
Generated at lib/blog_web/controllers/post_html.ex:
defmodule BlogWeb.PostHTML do
use BlogWeb, :html
embed_templates "post_html/*"
endThis embeds all templates from lib/blog_web/controllers/post_html/ directory.
HEEx Templates
HEEx (HTML + EEx) provides embedded Elixir in HTML.
Index template (lib/blog_web/controllers/post_html/index.html.heex):
<.header>
Listing Posts
<:actions>
<.link href={~p"/posts/new"}>
<.button>New Post</.button>
</.link>
</:actions>
</.header>
<.table id="posts" rows={@posts} row_click={&JS.navigate(~p"/posts/#{&1}")}>
<:col :let={post} label="Title"><%= post.title %></:col>
<:col :let={post} label="Body"><%= post.body %></:col>
<:col :let={post} label="Published"><%= post.published %></:col>
<:action :let={post}>
<.link navigate={~p"/posts/#{post}"}>Show</.link>
</:action>
<:action :let={post}>
<.link navigate={~p"/posts/#{post}/edit"}>Edit</.link>
</:action>
</.table>Key HEEx features:
<%= expression %>- Embed Elixir expression@assigns- Access controller assigns (e.g.,@posts)~p"/path"- Verified route helper<.component>- Component invocation
Show Template
<.header>
Post <%= @post.id %>
<:subtitle>This is a post record from your database.</:subtitle>
<:actions>
<.link href={~p"/posts/#{@post}/edit"}>
<.button>Edit post</.button>
</.link>
</:actions>
</.header>
<.list>
<:item title="Title"><%= @post.title %></:item>
<:item title="Body"><%= @post.body %></:item>
<:item title="Published"><%= @post.published %></:item>
</.list>
<.back navigate={~p"/posts"}>Back to posts</.back>Form Template
<.simple_form :let={f} for={@changeset} action={@action}>
<.error :if={@changeset.action}>
Oops, something went wrong! Please check the errors below.
</.error>
<.input field={f[:title]} type="text" label="Title" />
<.input field={f[:body]} type="textarea" label="Body" />
<.input field={f[:published]} type="checkbox" label="Published" />
<:actions>
<.button>Save Post</.button>
</:actions>
</.simple_form>Layouts - Shared Structure
Layouts wrap templates with common HTML structure.
Root Layout
Located at lib/blog_web/components/layouts/root.html.heex:
<!DOCTYPE html>
<html lang="en" class="[scrollbar-gutter:stable]">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<.live_title suffix=" · Phoenix Framework">
<%= assigns[:page_title] || "Blog" %>
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
</script>
</head>
<body class="bg-white antialiased">
<%= @inner_content %>
</body>
</html>@inner_content is where page content renders.
App Layout
Located at lib/blog_web/components/layouts/app.html.heex:
<header class="px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between border-b border-zinc-100 py-3">
<div class="flex items-center gap-4">
<a href="/">
<img src={~p"/images/logo.svg"} width="36" />
</a>
<p class="rounded-full bg-brand/5 px-2 text-[0.8125rem] font-medium leading-6">
v<%= Application.spec(:phoenix, :vsn) %>
</p>
</div>
<div class="flex items-center gap-4">
<a href="/posts" class="text-[0.8125rem] font-semibold leading-6">
Posts
</a>
</div>
</div>
</header>
<main class="px-4 py-20 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl">
<.flash_group flash={@flash} />
<%= @inner_content %>
</div>
</main>Set Page Title
In controller:
def show(conn, %{"id" => id}) do
post = Blog.get_post!(id)
conn
|> assign(:page_title, post.title)
|> render(:show, post: post)
endTemplate now has page_title available.
Ecto Basics - Database Interaction
Ecto is Phoenix’s database wrapper (similar to ActiveRecord in Rails).
Schema Definition
Generated at lib/blog/blog/post.ex:
defmodule Blog.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
@doc false
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :body, :published])
|> validate_required([:title, :body])
end
endSchema fields:
:string- VARCHAR:integer- INTEGER:boolean- BOOLEAN:date- DATE:utc_datetime- TIMESTAMP:text- TEXT
Basic Queries
alias Blog.Repo
alias Blog.Blog.Post
Repo.all(Post)
Repo.get(Post, 1)
Repo.get!(Post, 1) # Raises if not found
Repo.get_by(Post, title: "Hello")
Repo.get_by!(Post, title: "Hello")
%Post{title: "New Post", body: "Content"}
|> Repo.insert()
post = Repo.get!(Post, 1)
changeset = Ecto.Changeset.change(post, title: "Updated Title")
Repo.update(changeset)
post = Repo.get!(Post, 1)
Repo.delete(post)Query with Ecto.Query
import Ecto.Query
query = from p in Post,
where: p.published == true,
select: p
Repo.all(query)
query = from p in Post,
order_by: [desc: p.inserted_at],
limit: 10
Repo.all(query)
Post
|> where([p], p.published == true)
|> order_by([p], desc: p.inserted_at)
|> limit(10)
|> Repo.all()
Post
|> where([p], p.published == true)
|> Repo.aggregate(:count)Associations
Define relationships:
defmodule Blog.Blog.Post do
use Ecto.Schema
import Ecto.Changeset
schema "posts" do
field :title, :string
field :body, :string
field :published, :boolean, default: false
belongs_to :author, Blog.Accounts.User
has_many :comments, Blog.Blog.Comment
timestamps(type: :utc_datetime)
end
end
defmodule Blog.Blog.Comment do
use Ecto.Schema
schema "comments" do
field :body, :string
belongs_to :post, Blog.Blog.Post
timestamps(type: :utc_datetime)
end
endPreload associations:
post = Repo.get!(Post, 1) |> Repo.preload(:comments)
post.comments # => [%Comment{}, ...]
post = Repo.get!(Post, 1) |> Repo.preload([:comments, :author])
Post
|> where([p], p.published == true)
|> preload(:comments)
|> Repo.all()Changesets - Validation and Casting
Changesets validate and cast external data before database operations.
Basic Changeset
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :body, :published])
|> validate_required([:title, :body])
endcast/3 - Whitelist allowed fields
validate_required/2 - Ensure fields present
Validation Functions
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :body, :published, :view_count])
|> validate_required([:title, :body])
|> validate_length(:title, min: 3, max: 100)
|> validate_length(:body, min: 10)
|> validate_number(:view_count, greater_than_or_equal_to: 0)
|> validate_format(:title, ~r/^[a-zA-Z0-9 ]+$/)
|> unique_constraint(:title)
endCommon validations:
validate_length- String lengthvalidate_number- Numeric constraintsvalidate_format- Regex patternvalidate_inclusion- Value in listvalidate_exclusion- Value not in listunique_constraint- Database uniqueness
Custom Validation
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :body, :published])
|> validate_required([:title, :body])
|> validate_no_profanity()
end
defp validate_no_profanity(changeset) do
title = get_field(changeset, :title)
if title && String.contains?(String.downcase(title), ["bad", "words"]) do
add_error(changeset, :title, "contains inappropriate language")
else
changeset
end
endApply Changeset
attrs = %{title: "Valid Post", body: "Content here", published: true}
changeset = Post.changeset(%Post{}, attrs)
changeset.valid? # => true
Repo.insert(changeset) # => {:ok, %Post{}}
attrs = %{title: "AB", body: ""}
changeset = Post.changeset(%Post{}, attrs)
changeset.valid? # => false
changeset.errors # => [title: {"should be at least %{count} character(s)", ...}, body: {"can't be blank", ...}]
Repo.insert(changeset) # => {:error, %Ecto.Changeset{}}Contexts - Business Logic Organization
Contexts organize related functionality (similar to service objects).
Generated Context
Located at lib/blog/blog.ex:
defmodule Blog.Blog do
import Ecto.Query, warn: false
alias Blog.Repo
alias Blog.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
def change_post(%Post{} = post, attrs \\ %{}) do
Post.changeset(post, attrs)
end
endCustom Context Functions
Add business logic to context:
defmodule Blog.Blog do
# ... existing functions ...
def list_published_posts do
Post
|> where([p], p.published == true)
|> order_by([p], desc: p.inserted_at)
|> Repo.all()
end
def publish_post(%Post{} = post) do
post
|> Ecto.Changeset.change(published: true)
|> Repo.update()
end
def increment_view_count(%Post{} = post) do
{1, [updated_post]} =
Post
|> where(id: ^post.id)
|> Repo.update_all(inc: [view_count: 1])
|> Repo.all()
{:ok, updated_post}
end
def recent_posts(limit \\ 10) do
Post
|> order_by([p], desc: p.inserted_at)
|> limit(^limit)
|> Repo.all()
end
endControllers call context functions, never Repo directly:
defmodule BlogWeb.PostController do
use BlogWeb, :controller
alias Blog.Blog
def index(conn, _params) do
posts = Blog.list_published_posts() # Context function
render(conn, :index, posts: posts)
end
def show(conn, %{"id" => id}) do
post = Blog.get_post!(id)
Blog.increment_view_count(post) # Context function
render(conn, :show, post: post)
end
endLiveView - Real-Time Interactivity
LiveView enables real-time features without writing JavaScript.
Generate LiveView
mix phx.gen.live Counter Counter counters value:integerUpdate router:
scope "/", BlogWeb do
pipe_through :browser
get "/", PageController, :home
resources "/posts", PostController
live "/counters", CounterLive.Index
live "/counters/:id", CounterLive.Show
endBasic LiveView
Create simple counter (lib/blog_web/live/counter_live.ex):
defmodule BlogWeb.CounterLive do
use BlogWeb, :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="text-center">
<h1 class="text-4xl font-bold">Counter: <%= @count %></h1>
<div class="mt-4 space-x-4">
<button phx-click="decrement" class="px-4 py-2 bg-red-500 text-white rounded">
-
</button>
<button phx-click="increment" class="px-4 py-2 bg-green-500 text-white rounded">
+
</button>
</div>
</div>
"""
end
endAdd route:
scope "/", BlogWeb do
pipe_through :browser
live "/counter", CounterLive
endVisit http://localhost:4000/counter - clicking buttons updates count in real-time!
LiveView Lifecycle
defmodule BlogWeb.TimerLive do
use BlogWeb, :live_view
def mount(_params, _session, socket) do
if connected?(socket) do
:timer.send_interval(1000, self(), :tick)
end
{:ok, assign(socket, seconds: 0)}
end
def handle_info(:tick, socket) do
{:noreply, update(socket, :seconds, &(&1 + 1))}
end
def render(assigns) do
~H"""
<h1>Elapsed: <%= @seconds %> seconds</h1>
"""
end
endUpdates automatically every second!
Forms in LiveView
defmodule BlogWeb.SearchLive do
use BlogWeb, :live_view
alias Blog.Blog
def mount(_params, _session, socket) do
{:ok, assign(socket, query: "", results: [])}
end
def handle_event("search", %{"query" => query}, socket) do
results = search_posts(query)
{:noreply, assign(socket, query: query, results: results)}
end
defp search_posts(query) do
# Implement search logic
Blog.search_posts(query)
end
def render(assigns) do
~H"""
<form phx-change="search">
<input
type="text"
name="query"
value={@query}
placeholder="Search posts..."
class="border px-4 py-2 rounded"
/>
</form>
<div class="mt-4">
<%= for post <- @results do %>
<div class="p-4 border-b">
<h3><%= post.title %></h3>
<p><%= String.slice(post.body, 0..100) %></p>
</div>
<% end %>
</div>
"""
end
endSearch results update as you type - no page reload!
Channels - WebSocket Communication
Channels enable bidirectional real-time communication.
Define Channel
Create lib/blog_web/channels/room_channel.ex:
defmodule BlogWeb.RoomChannel do
use BlogWeb, :channel
def join("room:lobby", _payload, socket) do
{:ok, socket}
end
def join("room:" <> _private_room_id, _params, _socket) do
{:error, %{reason: "unauthorized"}}
end
def handle_in("new_msg", %{"body" => body}, socket) do
broadcast!(socket, "new_msg", %{body: body})
{:noreply, socket}
end
endRegister in lib/blog_web/channels/user_socket.ex:
defmodule BlogWeb.UserSocket do
use Phoenix.Socket
channel "room:*", BlogWeb.RoomChannel
def connect(_params, socket, _connect_info) do
{:ok, socket}
end
def id(_socket), do: nil
endClient JavaScript
In assets/js/app.js:
import { Socket } from "phoenix";
let socket = new Socket("/socket", { params: { token: window.userToken } });
socket.connect();
let channel = socket.channel("room:lobby", {});
let chatInput = document.querySelector("#chat-input");
let messagesContainer = document.querySelector("#messages");
chatInput.addEventListener("keypress", (event) => {
if (event.key === "Enter") {
channel.push("new_msg", { body: chatInput.value });
chatInput.value = "";
}
});
channel.on("new_msg", (payload) => {
let messageItem = document.createElement("li");
messageItem.innerText = payload.body;
messagesContainer.appendChild(messageItem);
});
channel
.join()
.receive("ok", (resp) => {
console.log("Joined successfully", resp);
})
.receive("error", (resp) => {
console.log("Unable to join", resp);
});Presence - Track Users
defmodule BlogWeb.Presence do
use Phoenix.Presence,
otp_app: :blog,
pubsub_server: Blog.PubSub
endTrack users in channel:
defmodule BlogWeb.RoomChannel do
use BlogWeb, :channel
alias BlogWeb.Presence
def join("room:lobby", _payload, socket) do
send(self(), :after_join)
{:ok, socket}
end
def handle_info(:after_join, socket) do
push(socket, "presence_state", Presence.list(socket))
{:ok, _} = Presence.track(socket, socket.assigns.user_id, %{
online_at: inspect(System.system_time(:second))
})
{:noreply, socket}
end
endAuthentication - User Login
Phoenix provides generator for authentication.
Generate Auth
mix phx.gen.auth Accounts User usersThis generates:
- User schema and migration
- Accounts context
- Controllers for registration, login, logout
- Session management
- Email confirmation
- Password reset
- Tests
Run migration:
mix ecto.migrateGenerated Routes
scope "/", BlogWeb do
pipe_through :browser
get "/", PageController, :home
# ... existing routes ...
end
scope "/", BlogWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated]
get "/users/register", UserRegistrationController, :new
post "/users/register", UserRegistrationController, :create
get "/users/log_in", UserSessionController, :new
post "/users/log_in", UserSessionController, :create
end
scope "/", BlogWeb do
pipe_through [:browser, :require_authenticated_user]
get "/users/settings", UserSettingsController, :edit
put "/users/settings", UserSettingsController, :update
end
scope "/", BlogWeb do
pipe_through [:browser]
delete "/users/log_out", UserSessionController, :delete
endProtect Routes
Require authentication:
scope "/", BlogWeb do
pipe_through [:browser, :require_authenticated_user]
resources "/posts", PostController, except: [:index, :show]
end
scope "/", BlogWeb do
pipe_through :browser
get "/", PageController, :home
resources "/posts", PostController, only: [:index, :show]
endNow only authenticated users can create/edit/delete posts.
Access Current User
In controllers:
def create(conn, %{"post" => post_params}) do
current_user = conn.assigns.current_user
post_params = Map.put(post_params, "author_id", current_user.id)
case Blog.create_post(post_params) do
{:ok, post} ->
conn
|> put_flash(:info, "Post created successfully.")
|> redirect(to: ~p"/posts/#{post}")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :new, changeset: changeset)
end
endIn templates:
<%= if @current_user do %>
<p>Welcome, <%= @current_user.email %>!</p>
<.link href={~p"/users/log_out"} method="delete">Log out</.link>
<% else %>
<.link href={~p"/users/register"}>Register</.link>
<.link href={~p"/users/log_in"}>Log in</.link>
<% end %>Deployment Basics
Deploy Phoenix to production.
Release Configuration
Phoenix uses Elixir releases for deployment.
Update config/runtime.exs:
if config_env() == :prod do
database_url =
System.get_env("DATABASE_URL") ||
raise """
environment variable DATABASE_URL is missing.
"""
config :blog, Blog.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 """
environment variable SECRET_KEY_BASE is missing.
"""
host = System.get_env("PHX_HOST") || "example.com"
port = String.to_integer(System.get_env("PORT") || "4000")
config :blog, BlogWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [
ip: {0, 0, 0, 0, 0, 0, 0, 0},
port: port
],
secret_key_base: secret_key_base
endBuild Release
export SECRET_KEY_BASE=$(mix phx.gen.secret)
export DATABASE_URL="postgresql://user:pass@localhost/blog_prod"
mix assets.deploy
MIX_ENV=prod mix releaseRun Release
_build/prod/rel/blog/bin/blog startDeploy to Fly.io
Phoenix has first-class Fly.io support:
curl -L https://fly.io/install.sh | sh
fly launch
fly deploy
fly ssh console
/app/bin/blog eval "Blog.Release.migrate"Summary
What you’ve learned:
- Request Lifecycle - Endpoint → Router → Pipeline → Controller → View → Template
- Routing - URL mapping, resource routes, route helpers
- Controllers - Request handling, pattern matching params, conn manipulation
- Views and Templates - HEEx syntax, embedded Elixir, component usage
- Layouts - Root and app layouts, shared structure
- Ecto - Schemas, queries, associations, preloading
- Changesets - Validation, casting, custom validators
- Contexts - Organizing business logic, separation of concerns
- LiveView - Real-time updates, event handling, stateful interactions
- Channels - WebSocket communication, broadcasting, presence
- Authentication - User registration, login, session management
- Deployment - Releases, configuration, production deployment
Key skills gained:
- Building full CRUD applications
- Real-time features without JavaScript
- Database interaction with Ecto
- User authentication and authorization
- RESTful API design
- Production deployment
Phoenix patterns mastered:
- MVC architecture with contexts
- Pattern matching in controllers
- Pipeline composition
- LiveView for interactivity
- Context-driven design
Next Steps
Ready for comprehensive Phoenix mastery?
- Beginner Tutorial (0-60% coverage) - Deep dive into Phoenix with extensive practice
- Intermediate Tutorial (60-85% coverage) - Advanced features, testing, deployment
Prefer code-first learning?
- By-Example Tutorial - 75-90 heavily annotated examples covering 95% of Phoenix
Want to understand design philosophy?
- Overview - Why Phoenix exists, when to use it, ecosystem overview
Need to strengthen Elixir fundamentals?
- Elixir Beginner Tutorial - Deep dive into Elixir language
Practice Projects
Build these projects to solidify Phoenix concepts:
1. Blog Platform
- User registration and authentication
- Create, edit, delete blog posts
- Comments system
- Markdown rendering
- Tags and categories
2. Real-Time Chat Application
- User authentication
- Multiple chat rooms
- LiveView or Channels
- User presence tracking
- Message history
3. Todo List with LiveView
- Real-time task updates
- Mark tasks complete
- Filter by status
- Drag-and-drop reordering
- User accounts
4. E-Commerce Store
- Product catalog
- Shopping cart (session-based)
- User accounts
- Order history
- Admin dashboard
Further Resources
Official Documentation:
- Phoenix Guides - Comprehensive official guides
- Phoenix API Docs - Complete API reference
- LiveView Docs - LiveView guide and API
- Ecto Guides - Database wrapper documentation
Books:
- Programming Phoenix - Comprehensive Phoenix book by framework authors
- Phoenix in Action - Practical Phoenix development
- Real-Time Phoenix - Channels, LiveView, and real-time features
Video Courses:
- Phoenix Framework Course - Pragmatic Studio
- ElixirCasts - Free Phoenix screencasts
Community:
- Elixir Forum - Active community discussion
- Phoenix Forum - Phoenix-specific help
- Elixir Slack #phoenix - Real-time chat
- Phoenix GitHub - Source code and issues