Phoenix Framework

Phoenix is the web framework we use for all the Elixir examples in this book. It follows the Model-View-Controller (MVC) pattern. If that acronym means nothing to you, do not worry: MVC is simply a convention about "what goes where" so that every Phoenix project looks familiar. After a handful of examples you will have a feel for it.

In this chapter we create simple dynamic webpages, one step at a time. We skip the database for now, so you can focus on the moving parts of the web layer: routes, controllers, and templates.

Phoenix version

This book is written against Phoenix 1.8.5 on Elixir 1.20 and Erlang/OTP 28. Please check the version of the installer on your machine:

$ mix phx.new --version
Phoenix installer v1.8.5

To install or update it, run mix archive.install hex phx_new.

Older tutorials sometimes mention mix phoenix.new or directories named templates/ and views/. Those are pre-1.7 conventions and no longer exist. Everything in this book uses the modern Phoenix 1.8 layout: templates live next to their controller, and there is no separate views/ folder.

Development environment

Phoenix knows three environments out of the box:

  • Development — verbose error pages, live code reload.

  • Test — used by mix test.

  • Production — the lean, optimised build that runs on your server.

We use the development environment throughout this chapter.

The base setup

The command mix phx.new <name> scaffolds a complete Phoenix project for you. We will call ours demo:

$ mix phx.new demo --no-ecto (1)
* creating demo/config/config.exs
* creating demo/config/dev.exs
[...]
Fetch and install dependencies? [Yn] Y (2)
[...]
We are almost there! The following steps are missing:

    $ cd demo

Start your Phoenix app with:

    $ mix phx.server

You can also run your app inside IEx (Interactive Elixir) as:

    $ iex -S mix phx.server
1 --no-ecto skips the database layer. We do not need it for the first examples. You will add a database later in the Ecto chapter.
2 Say Y. Phoenix will fetch all Hex dependencies and install the frontend asset pipeline. This step takes a moment the first time, then stays cached.

cd into the new directory and start the server:

$ cd demo
$ mix phx.server
[info] Running DemoWeb.Endpoint with Bandit 1.10.4 at 127.0.0.1:4000 (http) (1)
[info] Access DemoWeb.Endpoint at http://localhost:4000 (2)
[watch] build finished, watching for changes...
1 Phoenix 1.8 ships with Bandit as the default web server. Bandit is written in pure Elixir, supports HTTP/2, and replaces the older Cowboy adapter used in early Phoenix versions.
2 The URL that serves the development site.

Open http://localhost:4000 in your browser:

http://localhost:4000

The request log you see in the terminal looks like this:

[info] GET /
[debug] Processing with DemoWeb.PageController.home/2 (1)
  Parameters: %{}
  Pipelines: [:browser]
[info] Sent 200 in 22ms
1 The default route calls PageController.home/2, not :index, in Phoenix 1.8.

Reload the page and you will see sub-millisecond response times:

[info] GET /
[debug] Processing with DemoWeb.PageController.home/2
  Parameters: %{}
  Pipelines: [:browser]
[info] Sent 200 in 426µs
426 microseconds. Yes, Phoenix really is that fast, even on a laptop.

Where the page comes from

The page you are looking at is rendered from a HEEx template. HEEx stands for "HTML + Embedded Elixir" and is Phoenix’s modern templating language. HEEx files end in .html.heex and live next to their controller:

$ tree lib/demo_web/controllers
lib/demo_web/controllers
├── error_html.ex
├── error_json.ex
├── page_controller.ex
├── page_html
│   └── home.html.heex (1)
└── page_html.ex (2)
1 The HEEx template that renders the home page.
2 The HTML module that hosts the template. This replaces the old "view" concept from Phoenix 1.6 and earlier.
Because our application is called demo, Phoenix names its web module DemoWeb and puts it under lib/demo_web/. The matching Elixir module for the home-page template is DemoWeb.PageHTML.

Open lib/demo_web/controllers/page_html/home.html.heex and you will see the welcome page markup. Every template has access to @conn (the request), any assigns you set, and layout components shared across the app.

The outer chrome (the <html>, <head>, and <body> tags) comes from lib/demo_web/components/layouts/root.html.heex. Layout and flash handling is now managed by a tiny Layouts module so every page gets the same shell without repeating HTML.

HEEx replaces the older .eex templates. Inside HEEx you can write HTML as usual, embed Elixir expressions with { … } inside attributes or {% = … %}-style blocks in bodies, and use function components such as <.link /> and <.icon />. Do not write raw <%= … %> tags: HEEx is strict and knows the HTML structure it is rendering.

Feel free to edit the home template while the browser is open. Phoenix watches the file and live-reloads the page after each save.

Agentic Coding Tip: Always Let the Agent Use mix phx.gen.*

Claude is fully capable of hand-writing a Phoenix controller, a LiveView, an Ecto schema, a migration, and its matching test. It is also happy to do so when asked. You don’t want this. A hand-written migration skips the timestamp prefix convention. A hand-written LiveView forgets the companion index.html.heex or the matching *_live_test.exs. A hand-written context glues business logic into a controller.

Phoenix ships a full family of generators that enforce the conventions and the companion files the framework expects:

  • mix phx.gen.html — controller + views + templates
    context + schema + migration + tests (CRUD scaffold).

  • mix phx.gen.live — LiveView equivalent.

  • mix phx.gen.context — context + schema + migration only (when you don’t want the web layer yet).

  • mix phx.gen.schema — schema + migration.

  • mix phx.gen.auth — full email/password + magic-link authentication (see the Authentication chapter).

  • mix phx.gen.json, mix phx.gen.channel, mix phx.gen.presence, and more.

Rule to add to your project’s CLAUDE.md:

For any Phoenix artefact that has a generator (context,
schema, migration, controller, LiveView, auth, channel,
presence), always use `mix phx.gen.*` instead of writing
the files by hand. Show me the exact generator command
before running it. If you believe a hand-written file is
genuinely needed, stop and explain why.

The "show me the command first" part matters: the agent will sometimes reach for mix phx.gen.html when mix phx.gen.live was the right move, or pick the wrong field types (:string vs :text vs :utc_datetime). Seeing the command in the conversation lets you veto in one second, before any file lands on disk.

Hello World!

Our goal: a dynamic page at http://localhost:4000/hello that simply prints "Hello World!". We will do it the idiomatic Phoenix 1.8 way, with a new route, a controller action, and a template.

If you do not already have a demo app running from the previous section, create one now:

$ mix phx.new demo --no-ecto
[...]
$ cd demo
$ mix phx.server

Step 1: add a route

Open lib/demo_web/router.ex and add one line:

lib/demo_web/router.ex
defmodule DemoWeb.Router do
  use DemoWeb, :router

  [...]

  scope "/", DemoWeb do
    pipe_through :browser

    get "/", PageController, :home
    get "/hello", PageController, :hello (1)
  end

  [...]
1 A new GET /hello route that points at the hello action of the already-existing PageController.
pipe_through :browser tells Phoenix to run every request in this block through the :browser pipeline (session handling, CSRF protection, and so on). We cover pipelines properly in the Router chapter. For now just leave that line where it is.

Step 2: add the controller action

Open lib/demo_web/controllers/page_controller.ex and add a matching hello/2 function:

lib/demo_web/controllers/page_controller.ex
defmodule DemoWeb.PageController do
  use DemoWeb, :controller

  def home(conn, _params) do
    render(conn, :home)
  end

  def hello(conn, _params) do (1)
    render(conn, :hello)
  end
end
1 render(conn, :hello) renders the template called hello.html.heex that belongs to PageHTML.

Step 3: add the template

Create the file lib/demo_web/controllers/page_html/hello.html.heex:

lib/demo_web/controllers/page_html/hello.html.heex
<h1 class="text-3xl font-bold">Hello World!</h1>

Open http://localhost:4000/hello in your browser. You should see the headline. Phoenix live-reloads on save, so no restart needed.

http://localhost:4000/hello

Hello World with its own controller

Sharing an action with PageController was fine for this tiny example. In a real app, each concern usually gets its own controller. Let us split the hello action into a dedicated ExampleController.

Update the router:

lib/demo_web/router.ex
  scope "/", DemoWeb do
    pipe_through :browser

    get "/", PageController, :home
    get "/hello", ExampleController, :hello (1)
  end
1 ExampleController is clearly not a production-worthy name. Feel free to rename it once you see the pattern.

Reload http://localhost:4000/hello. Phoenix will complain that the module does not exist. That is fine, the error message tells us exactly what to do: create the controller.

Create lib/demo_web/controllers/example_controller.ex:

lib/demo_web/controllers/example_controller.ex
defmodule DemoWeb.ExampleController do
  use DemoWeb, :controller

  def hello(conn, _params) do
    render(conn, :hello)
  end
end

Phoenix 1.8 auto-infers the HTML module (DemoWeb.ExampleHTML) from the controller name. Create that module next:

lib/demo_web/controllers/example_html.ex
defmodule DemoWeb.ExampleHTML do
  use DemoWeb, :html

  embed_templates "example_html/*"
end

The embed_templates "example_html/*" macro picks up every .heex file under lib/demo_web/controllers/example_html/ and exposes each one as a function component.

Finally, create the template itself:

lib/demo_web/controllers/example_html/hello.html.heex
<h1 class="text-3xl font-bold">Hello World!</h1>

Reload the page, and you should see the greeting again, now served by the new dedicated controller.

Checklist for a new page

Every time you want to add a new page in a new controller, you take these four small steps:

  • Add a route in lib/demo_web/router.ex.

  • Add a controller at lib/demo_web/controllers/<name>_controller.ex with the matching action.

  • Add an HTML module at lib/demo_web/controllers/<name>_html.ex that embeds its templates.

  • Create the template file under lib/demo_web/controllers/<name>_html/<action>.html.heex.

Phoenix’s error messages in development mode guide you through missing pieces one at a time. If you forget a step, the browser will tell you which file is missing next.

The conn Struct

In the MVC world, the controller runs your code and the template displays the result. The controller has to pass data to the template somehow; that vehicle is the %Plug.Conn{} struct, usually just called conn.

Think of conn as the entire request-and-response context: incoming headers, URL, parameters, and whatever data you choose to hand over to the template. Every controller action gets a conn and must return a conn.

Let us add a diagnostic route so we can actually look at one. In the demo application:

lib/demo_web/router.ex
  scope "/", DemoWeb do
    pipe_through :browser

    get "/", PageController, :home
    get "/inspect", PageController, :inspect (1)
    get "/playground", PageController, :playground
  end
1 Two new routes pointing at new actions we are about to write.

Add the inspect and playground actions to PageController:

lib/demo_web/controllers/page_controller.ex
defmodule DemoWeb.PageController do
  use DemoWeb, :controller

  def home(conn, _params) do
    render(conn, :home)
  end

  def inspect(conn, _params) do
    render(conn, :inspect)
  end

  def playground(conn, _params) do
    render(conn, :playground)
  end
end

Then create two HEEx templates next to the existing home.html.heex:

lib/demo_web/controllers/page_html/inspect.html.heex
<pre class="whitespace-pre-wrap text-xs">
{inspect(@conn, pretty: true)} (1)
</pre>
1 HEEx prints the value of an Elixir expression with { … }. The @conn assign is always available and holds the current request.
lib/demo_web/controllers/page_html/playground.html.heex
<table>
  <tr><td>Host:</td><td>{@conn.host}</td></tr>
  <tr><td>Port:</td><td>{@conn.port}</td></tr>
</table>

Open http://localhost:4000/inspect and you will see the full conn struct printed out. It is huge; we only ever need a small slice of it. A trimmed excerpt looks roughly like this in Phoenix 1.8:

%Plug.Conn{
  adapter: {Bandit.Adapter, :...},
  assigns: %{},
  host: "localhost",
  method: "GET",
  owner: #PID<0.855.0>,
  params: %{},
  path_info: ["inspect"],
  port: 4000,
  remote_ip: {127, 0, 0, 1},
  request_path: "/inspect",
  scheme: :http,
  status: nil,
  ...
}
The adapter line now says Bandit.Adapter (Phoenix 1.8’s default web server), not Plug.Cowboy.Conn.

Passing data to the template

Controllers put data into conn.assigns with assign/3. Templates read it back using the @ shortcut.

lib/demo_web/controllers/page_controller.ex
defmodule DemoWeb.PageController do
  use DemoWeb, :controller

  def home(conn, _params) do
    render(conn, :home)
  end

  def inspect(conn, _params) do
    conn
    |> assign(:headline, "This is a test headline") (1)
    |> render(:inspect)
  end

  def playground(conn, _params) do
    headline = "This is a test headline"

    conn
    |> assign(:headline, headline) (2)
    |> render(:playground)
  end
end
1 assign/3 adds :headline to the assigns map on conn. It returns the updated conn so you can pipe it into render.
2 Same idea, but with the headline computed in a separate variable first.

Update the playground template to use the new assign:

lib/demo_web/controllers/page_html/playground.html.heex
<h1 class="text-2xl font-bold">{@headline}</h1>

<table>
  <tr>
    <td>@conn.assigns.headline</td>
    <td>{@conn.assigns.headline}</td> (1)
  </tr>
  <tr>
    <td>@headline</td>
    <td>{@headline}</td> (2)
  </tr>
</table>
1 You can always reach into conn.assigns by its full path.
2 Every assign is also accessible via the @ shortcut. Prefer this form, it is shorter and more readable.
http://localhost:4000/playground

Static Clock

Right now, the page always says the same thing. The smallest possible step to make it dynamic is to show the current time.

lib/demo_web/controllers/page_controller.ex
[...]
def playground(conn, _params) do
  headline = "This is a test headline"
  {:ok, timestamp} = DateTime.now("Etc/UTC")

  conn
  |> assign(:headline, headline)
  |> assign(:timestamp, timestamp)
  |> render(:playground)
end
[...]

And the template:

lib/demo_web/controllers/page_html/playground.html.heex
<h1 class="text-2xl font-bold">{@headline}</h1>

<table>
  <tr>
    <td>Etc/UTC</td>
    <td>{@timestamp}</td>
  </tr>
</table>
http://localhost:4000/playground

Refresh the page and the timestamp updates on every reload. For a clock that ticks without a manual reload, we will use LiveView in the next chapter.

Webpages link to each other. Our next toy app is a two-page game of ping-pong: /ping links to /pong and vice versa.

$ mix phx.new game --no-ecto
[...]
$ cd game
$ mix phx.server

Add the two routes:

lib/game_web/router.ex
  scope "/", GameWeb do
    pipe_through :browser

    get "/", PageController, :home
    get "/ping", PageController, :ping
    get "/pong", PageController, :pong
  end

The actions:

lib/game_web/controllers/page_controller.ex
defmodule GameWeb.PageController do
  use GameWeb, :controller

  def home(conn, _params) do
    render(conn, :home)
  end

  def ping(conn, _params) do
    render(conn, :ping)
  end

  def pong(conn, _params) do
    render(conn, :pong)
  end
end

And the two templates:

lib/game_web/controllers/page_html/ping.html.heex
<h1 class="text-3xl font-bold">Ping</h1>
lib/game_web/controllers/page_html/pong.html.heex
<h1 class="text-3xl font-bold">Pong</h1>

Now we need links between the two pages. We could hard-code <a href="/pong">, but Phoenix gives us something better: verified routes via the ~p sigil.

$ mix phx.routes | grep PageController
  GET  /       GameWeb.PageController :home
  GET  /ping   GameWeb.PageController :ping
  GET  /pong   GameWeb.PageController :pong

Update the two templates to link to each other with ~p:

lib/game_web/controllers/page_html/ping.html.heex
<h1 class="text-3xl font-bold">Ping</h1>

<p>
  <.link href={~p"/pong"}>Pong!</.link> (1)
</p>
1 ~p"/pong" is a verified route. Phoenix checks it at compile time against the router and errors out if the path does not exist. Typos stop being a runtime surprise.
lib/game_web/controllers/page_html/pong.html.heex
<h1 class="text-3xl font-bold">Pong</h1>

<p>
  <.link href={~p"/ping"}>Ping!</.link>
</p>
http://localhost:4000/ping

Now you can play HTML ping-pong by clicking back and forth.

More on routes with parameters and query strings in the Router chapter.

The <.link> component accepts any HTML attribute, including class:

lib/game_web/controllers/page_html/pong.html.heex
<h1 class="text-3xl font-bold">Pong</h1>

<p>
  <.link href={~p"/ping"} class="btn btn-primary">Ping!</.link>
</p>

Since Phoenix 1.8 ships with Tailwind CSS and daisyUI out of the box, classes like btn and btn-primary immediately produce a styled button.

Static files

Static files (images, robots.txt, favicon.ico, PDFs, fonts, …) live under priv/static/. Phoenix serves them directly, without running any of your Elixir code.

$ tree priv/static
priv/static
├── favicon.ico
├── images
│   └── logo.svg
└── robots.txt

Two things control which files are exposed:

  1. The list in DemoWeb.static_paths/0 (in lib/demo_web.ex), which declares which top-level folders under priv/static are public.

  2. The Plug.Static plug in lib/demo_web/endpoint.ex, which is already wired up for you.

If you add a brand-new directory like pdfs/, add it to static_paths/0:

lib/demo_web.ex
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt pdfs) (1)
1 We added pdfs to the allow-list.

Images

Images are just static files, so the rules above apply. The generator places a Phoenix logo at priv/static/images/logo.svg which is already exposed.

To render an image from a HEEx template, use a verified static path with ~p"/images/…":

<img src={~p"/images/logo.svg"} alt="Phoenix Framework Logo" />

CSS

Phoenix 1.8 generates projects with Tailwind CSS v4 and daisyUI v5 pre-wired. You write classes directly in your HEEx templates and the development server recompiles the stylesheet on save.

We do not dive deep into CSS in this book, but a minimal example:

lib/game_web/controllers/page_html/ping.html.heex
<div class="card bg-base-200 p-6 max-w-sm">
  <h1 class="text-3xl font-bold">Ping</h1>
  <p class="mt-2">
    <.link class="btn btn-primary" href={~p"/pong"}>Pong!</.link>
  </p>
</div>

Reload the browser, and you will see a styled card with a blue button. Dark mode is handled automatically by Tailwind and daisyUI following the user’s system preference; there is no manual toggle needed.

If you want to override the generated assets/css/app.css, you can add Tailwind @theme tokens or plain CSS, and the dev server will rebuild the bundle on save.