7 min read
On this page

Phoenix Basics

Phoenix is the web framework that makes Elixir's concurrency story practical for everyday HTTP work. It's what Bleacher Report uses to push live game updates to millions of subscribers, what Discord uses for parts of its API surface, and what Pinterest uses for some of its real-time systems. The framework's reputation for speed is real — but the more important point is that Phoenix doesn't get in your way the way most frameworks do. It's small, it's composable, and most of it is just Elixir.

This isn't Rails-with-Elixir-syntax. The mental model is different, the layering is different, and the parts of your app that scale matter for different reasons. If you've used Rails, Django, or Express, the surface familiarity will help and the deeper differences will sometimes confuse you.

What Phoenix Is

Phoenix is a few things stacked on top of each other:

  1. Plug — the underlying composable middleware library, similar in spirit to Rack or WSGI.
  2. Phoenix.Endpoint — the HTTP entry point.
  3. Phoenix.Router — pattern matches incoming requests to controllers.
  4. Phoenix.Controller — handles the request, builds a response.
  5. Phoenix.LiveView — server-rendered, stateful UI over WebSockets (covered in its own topic).
  6. Phoenix.Channel — bidirectional WebSocket messaging for real-time features.

You can use just Plug for a small API. You can use Phoenix without LiveView. Each layer is independent enough that you can drop down to a lower level when you need to.

The framework deliberately stays out of your business logic. There's no equivalent of ActiveRecord or Django's ORM baked in — Ecto is a separate library that you bring in for database work. Phoenix handles the web layer; Ecto handles persistence; your application code lives in between as plain Elixir modules.

Creating a Project

mix archive.install hex phx_new
mix phx.new hello --database postgres
cd hello
mix ecto.create
mix phx.server

mix phx.new generates a project with sensible defaults. The --no-html, --no-assets, --no-ecto, and --no-live flags strip out parts you don't need — building a JSON API that doesn't need HTML rendering or LiveView is a one-flag affair.

The generated structure:

lib/
  hello/                  # business logic — pure Elixir
    application.ex        # OTP application & supervision tree
    repo.ex               # Ecto repo
  hello_web/              # web layer
    controllers/
    components/
    router.ex
    endpoint.ex
    telemetry.ex
  hello.ex
  hello_web.ex
priv/
  repo/migrations/
  static/
test/
config/
mix.exs

The split between hello/ and hello_web/ is the central convention — business logic lives in the unprefixed namespace, web concerns live in *_web. The web layer calls into hello/; hello/ doesn't know hello_web/ exists. We cover this in detail in the contexts chapter.

The Endpoint

HelloWeb.Endpoint is the HTTP entry point. It's a Plug pipeline that runs before the router — handling things like static files, parsers, sessions, and CSRF protection.

defmodule HelloWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :hello

  socket "/live", Phoenix.LiveView.Socket,
    websocket: [connect_info: [session: @session_options]]

  plug Plug.Static,
    at: "/",
    from: :hello,
    only: HelloWeb.static_paths()

  plug Plug.RequestId
  plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]

  plug Plug.Parsers,
    parsers: [:urlencoded, :multipart, :json],
    pass: ["*/*"],
    json_decoder: Phoenix.json_library()

  plug Plug.MethodOverride
  plug Plug.Head
  plug Plug.Session, @session_options
  plug HelloWeb.Router
end

Each plug is a step in processing the request. The router itself is a plug — it dispatches to controllers based on the path and method.

You'll rarely edit the endpoint after the initial setup, but knowing what's in it helps when you're debugging "why is this request behaving weirdly" — the answer is usually somewhere in this pipeline.

The Router

The router is where you map URLs to controllers and pipelines:

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

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :home
    resources "/articles", ArticleController
    live "/dashboard", DashboardLive
  end

  scope "/api", HelloWeb.API do
    pipe_through :api

    resources "/users", UserController, except: [:new, :edit]
    post "/auth/login", SessionController, :create
  end
end

A pipeline is a named group of plugs. A scope groups routes that share a pipeline and a controller namespace. Routes can be get, post, put, patch, delete, or resources (which expands to a full set of CRUD routes).

mix phx.routes prints every route in the application — useful when you're trying to remember the URL for an action or debugging a 404.

Plug as the Foundation

Plug is the abstraction underneath everything. A plug is one of two things:

  1. A function plugdef name(conn, opts) do ... end, returns a conn.
  2. A module plug — a module with init/1 and call/2 callbacks.
# function plug
def assign_user(conn, _opts) do
  user_id = get_session(conn, :user_id)
  user = user_id && MyApp.Accounts.get_user(user_id)
  assign(conn, :current_user, user)
end

# module plug
defmodule HelloWeb.Plugs.RequireAuth do
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, _opts) do
    if conn.assigns[:current_user] do
      conn
    else
      conn
      |> put_status(:unauthorized)
      |> Phoenix.Controller.json(%{error: "unauthorized"})
      |> halt()
    end
  end
end

halt/1 stops the plug pipeline — if you don't call it, even after sending a response, downstream plugs still run. Forgetting halt after an early return is a common bug.

The conn (a Plug.Conn struct) is the request and response together — headers, params, assigns, status, body, everything. Every plug receives it, transforms it, and returns it. The whole framework is functions over conns.

The Request Lifecycle

When a request hits Phoenix:

  1. Endpoint receives it, runs static-file serving, parsers, session, etc.
  2. Router matches the path and method to a route.
  3. The matched route's pipeline runs (browser, api, custom auth, etc.).
  4. The controller action runs, building the response.
  5. If the action calls render, the template is rendered.
  6. The conn flows back out, response is sent.

Every step is a plug. Every step receives and returns a conn. There's no magic — you can put IO.inspect(conn) anywhere and see exactly what's happening.

Comparison to Rails and Django

Rails and Django are full-stack frameworks with strong conventions and a lot of magic. Phoenix is more like a kit of well-fitting parts. Some specific differences:

No ORM in the framework. Ecto is separate. You don't get models with has_many and lifecycle callbacks. You get schemas (data shape), changesets (validation and casting), and a Repo (the database). It's more code than Rails, and it's also more honest — your domain logic isn't tangled up with persistence concerns by default.

No fat models or thin controllers debate. Phoenix's answer is contexts — modules that group related domain logic. Controllers stay thin because they call into contexts; contexts stay testable because they're plain Elixir modules.

No middleware mystery. The whole pipeline is visible in the endpoint and router. You can read it top to bottom. There's no hidden global state.

Concurrency is free. Each request runs in its own process. You don't worry about thread safety because there's no shared mutable state. A slow controller action doesn't block other requests.

LiveView changes the game. Once you've used LiveView, building anything moderately interactive in Rails or Django feels like writing two apps — a backend and a JavaScript frontend that re-implements your validation and state. LiveView keeps state on the server, sends diffs over WebSockets, and handles the UI without a separate frontend codebase.

The trade-off is ecosystem size. Rails has 15+ years of gems for everything; Phoenix's hex packages are smaller in number and sometimes less polished. For most use cases, the core libraries are excellent — Phoenix, Ecto, Oban (background jobs), Bandit (HTTP server), Tesla (HTTP client). For niche needs, you sometimes write more yourself.

A Minimal Working Endpoint

To prove how thin the framework is, here's a complete Plug-only "Hello World" without Phoenix:

defmodule MyApp.Router do
  use Plug.Router

  plug :match
  plug :dispatch

  get "/" do
    send_resp(conn, 200, "Hello!")
  end

  match _ do
    send_resp(conn, 404, "not found")
  end
end

# in your application
{Plug.Cowboy, scheme: :http, plug: MyApp.Router, port: 4000}

Phoenix is just a richer version of this — the same fundamental abstraction, with conventions for routing, controllers, templates, sockets, and sessions layered on top.

Common Pitfalls

Forgetting halt/1 after early returns. Sending an unauthorized response and then continuing the pipeline can produce surprising behavior — usually a Plug.Conn.AlreadySentError or duplicate responses. Always halt when you're terminating the request.

Putting business logic in controllers. Controllers should be thin — parse params, call into contexts, render the result. When you find yourself with a 200-line controller action, you're missing a context.

Treating assign like a session. conn.assigns lives for one request. It's not a session, not a cache, not persistent. For per-user state across requests, use Plug.Session. For server-side state, use a GenServer or ETS.

Confusing routes that look similar. resources "/users" generates seven routes; get "/users", ... generates one. mix phx.routes exists for a reason — use it when you're not sure what's defined.

Skipping the endpoint. New developers sometimes look at the router and assume that's the whole story. The endpoint runs first, and a bug in your parsing, sessions, or static handling shows up there before the router ever sees the request.

Key Takeaways

  • Phoenix is a stack of small libraries — Plug, Endpoint, Router, Controller — not a monolithic framework.
  • The endpoint is the HTTP entry point; the router maps URLs to controllers via pipelines.
  • Plug is the foundation: every layer of Phoenix is a plug, and the conn flows through them.
  • The split between lib/my_app/ and lib/my_app_web/ separates business logic from the web layer.
  • Compared to Rails or Django, Phoenix has less magic and more visible structure. The trade-off is more setup; the win is fewer surprises.
  • LiveView and Channels are why people pick Phoenix in the first place — but you don't have to use them.