I Can Haz Phoenix?
Monday, 16 Oct 2023
Phoenix is an incredible web framework, but how does it even work under the hood?
We’ll take a tour from the default web page, through the various files, and end up building our own simple web form.
Build a default app & run it
$ mix phx.new --binary-id --no-tailwind forms && cd forms
$ iex --name forms -S mix phx.server
Generated forms app
[info] Running FormsWeb.Endpoint with Bandit 1.0.0 at 127.0.0.1:4000 (http)
[info] Access FormsWeb.Endpoint at http://localhost:4000
[watch] build finished, watching for changes...
/
getting to the default page
When we go to the default page, /
what happens?
First up, our browser opens a new network connection via a TCP socket, to the Phoenix server’s endpoint.
This is provided by a webserver. In Phoenix parlance, this is called an adapter, and in our case we are using Bandit to provide this. It’s a great webserver, designed specifically for Phoenix, and written in Elixir. The Phoenix endpoint is the top-level application that the Erlang VM starts, that runs Bandit on our behalf.
However we’re not interested in this part at the moment, so let’s stay above the networking layer, and just focus on what Phoenix is doing. Just remember that the webserver Bandit, is started automatically for us by Elixir, via the endpoint, and the endpoint hands over control of each new connection to Phoenix for us.
Browsing to http://localhost:4000/ we are shown the default Phoenix page,
and in our iex
console, we also see:
[info] GET /
[debug] Processing with FormsWeb.PageController.home/2
Parameters: %{}
Pipelines: [:browser]
[info] Sent 200 in 158ms
🧪
OK! we can see our app received a GET /
request, and that this was handed
over to the FormsWeb.PageController.home/2
function, with an empty map of
parameters, and through the browser
pipeline.
Looking for pipeline
or :browser
in our application files, we find this
is in the forms_web/router.ex
file.
But how did the request get from the endpoint to the router? And how
did the router know where to send, or dispatch it? Looking for Endpoint
in the code, we see a file called forms_web/endpoint.ex
. This is actually
hard-wired in various config/config.*
files.
This links the starting application to the endpoint module that runs for every request.
Looking at the first and last few lines, skipping the middle we can see how
the endpoint module finally ends up calling the FormsWeb.Router
module:
defmodule FormsWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :forms
...
plug Plug.Static,
at: "/",
...
plug Plug.Session, @session_options
plug FormsWeb.Router
end
Again, let’s defer digging deeper into Plug, just noticing that it appears repeatedly, handling various types of functionality, from serving static files, to reloading, telemetry, parsers, and session handling. It looks stackable, or composable.
Let’s leave plugs and the endpoint, and head over to the router via
FormsWeb.Router
, which is the final plug
in the endpoint module. This
is defined in forms_web/router.ex
.
the router
defmodule FormsWeb.Router do
use FormsWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {FormsWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
...
The router looks very similar to the endpoint. It receives a web request from the endpoint, and passes it through the defined pipeline of plugs, each one refining the request further.
Each plug is a function that complies with a common set of input & output parameters, so that they can be chained together. The plug uses a conn struct to achieve this.
This may seem confusing at first, but the router is handing the conn from the webserver, repeatedly through a list of functions in the pipeline, each one modifying the conn as it needs to, building up headers and parameters incrementally.
Because of this standard struct, anybody can write a custom plug that matches the same interface, or typespec (function arity, both as parameters and returned types), to do anything from logging, to authentication and redirection.
The finalised conn is the result of the webserver breaking up all the raw strings sent via the browser, validating them, and turning them into the specific page requested, the type of web request, and any further URL parameters or settings that may be present, for us.
This is passed into a scope, which is a convenience to group routes with common pipelines and plugs. Within the scope, finally there is the link from the request to the module that handles it:
defmodule FormsWeb.Router do
use FormsWeb, :router
...
scope "/", FormsWeb do
pipe_through :browser
get "/", PageController, :home
end
...
It looks like get "/"
defines a relationship between the request method
GET
and request path /
to a module PageController
, and presumably a
function, :home
.
Each function receives the processed conn, as a%Plug.Conn{}
struct that
was already extracted from the web request, through the plug pipeline.
the controller
forms_web/controllers/page_controller.ex
is a pretty short file, with a
single home
function as expected:
defmodule FormsWeb.PageController do
use FormsWeb, :controller
def home(conn, _params) do
# The home page is often custom made,
# so skip the default app layout.
render(conn, :home, layout: false)
end
end
render/3 takes 3 parameters:
-
the
%Plug.Conn{}
struct - a template name
- assigns that are merged overtop of assigns within the conn
The template name is used to define which template is used to render the web page. This is finally where the user-supplied query is passed to a function that can generate the actual page.
If you remove layout: false
and use render/2
instead, you’ll see a
header injected at the top of the page. This is from app.html.heex
, if
you search for Get Started
in the code.
templates and layouts
All renderable templates end in eex
- there are a few types, but for
the moment only the .heex
ones are of interest.
Layouts are containers that templates can be rendered inside. A great example is the standard metadata, headers, and nav components that are displayed on every page. If you’re rendering plain text, or structured data like JSON or XML, likely the layout is not required.
Looking again for files called home
and that end in .heex
there is
forms_web/controllers/page_html/home.html.heex
.
The single most obvious thing about this file is that it’s not valid HTML, there is no doctype, no head, no meta tags, nor any body.
Somewhere there is a wrapper somewhere that this is inserted into. Let’s see where that is.
The easiest way to find this is to search for <!DOCTYPE html>
which is
used by the web browser to identify that this page is HTML5 and not a
preceding version. Luckily, searching for DOCTYPE
alone is sufficient, and
this leads us to a layout in forms_web/components/layouts/root.html.heex
.
This leads us to the overall structure:
root.html.heex
provides the outer-most layer for all web pages-
if
layout: false
is set,app.html.heex
is included in the body - next, the requested template is rendered
- in many cases, this template will include a component which provides flash messages and some error handling if the server websocket connection is lost
walking the templates
root.html.heex
the outer layout
The @inner_content
includes different components depending on whether
layout: false
is set. It’s also possible to select different layouts
but let’s not cover this just yet.
<!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] || "Forms" %>
</.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>
Note the {~p"/..."}
sigil that ensures routes are verified at compile
time, rather than waiting for a run-time crash.
app.html.heex
providing headers
This is ideal for nav
bars and other similar header functionality, while
users are logged-in or working within your application.
This is trimmed for clarity - here we can see a typical template insertion
where the <%= ... %>
operator allows running normal elixir functions:
<header>
...
<p>
v<%= Application.spec(:phoenix, :vsn) %>
</p>
...
</header>
<main>
<div>
<.flash_group flash={@flash} />
<%= @inner_content %>
</div>
</main>
Finally a further assigns is included, <%= @inner_content %>
which fetches
the requested :home
template itself. This file is located under
forms_web/controller/page_html/
. Other controller’s templates will live
in their corresponding private folder.
home.html.heex
the final content
OK that’s not quite true. The second line in this template pulls in a Core Component template, which is a generated component that provides a few liveview related helpers, tracking connected state, adding javascript hooks, and so forth.
At this point we are mainly interested in the syntax to define links, and
how the <.xyz />
syntax is used to inject or invoke components to be
included in the rendered template.
<link phx-track-static rel="stylesheet" href={~p"/assets/home.css"} />
<.flash_group flash={@flash} />
...
Let’s add a new page, to accept messages from visitors to our site. It’s
possible to do this via mix phx.gen.html
and similar generators as well,
but rolling it by hand is the path to understanding.
/contact
a new page
We now need to add a route, controller, views, and templates.
/contact
router
We’ll need to add two routes in forms_web/router.ex
:
- GET for displaying the form
- POST for handling the form submission from the browser to our app
scope "/", FormsWeb do
pipe_through :browser
get "/", PageController, :home
get "/contact", ContactController, :new # added
post "/contact", ContactController, :create # added
end
Use the phx.routes
command to validate this is correct:
> mix phx.routes
Compiling 1 file (.ex)
warning: FormsWeb.ContactController.init/1 is undefined (module FormsWeb.ContactController is not available or is yet to be defined)
Invalid call found at 2 locations:
lib/forms_web/router.ex:21: FormsWeb.Router.__checks__/0
lib/forms_web/router.ex:22: FormsWeb.Router.__checks__/0
GET / FormsWeb.PageController :home
GET /contact FormsWeb.ContactController :new
POST /contact FormsWeb.ContactController :create
...
/contact
controller
The error message for the missing page leads us to the next step, adding a controller.
The controller is responsible for choosing the correct function to call,
and passing in any parameters needed. These functions take parameters,
and call a render/2
function that returns the required HTML.
app.
For the moment, continue using layout: false
to keep the generated HTML
simple – remember this just excludes the title bar and headers from the
page, which is injected via .../layouts/app.html.heex
.
# lib/forms_web/controllers/contact_controller.ex
defmodule FormsWeb.ContactController do
use FormsWeb, :controller
def new(conn, _params) do
# returns a simple HTML form
render(conn, "new.html", layout: false)
end
def send(conn, _params) do
# basically write a flash saying thanks for your comment
render(conn, "send.html")
end
end
When this page is requested, ContactController.new/2
is called. The
function render(conn, "new.html", layout: false)
will only render
the outer root.html.heex
layout, because layout: false
is set,
and fail with:
ArgumentError at GET /contact no “new” html template defined for FormsWeb.ContactHTML (the module does not exist) the module exists but does not define new/1 nor render/2)
ContactHTML
view
The ContactHTML
module is a small wrapper around the HTML .heex
template which we’ll add soon. It’s a good place to add small bits of
functionality that aren’t dependent on the application’s data model, like
date. These functions are directly accessible inside the template:
# lib/forms_web/controllers/contact_html.ex
defmodule FormsWeb.ContactHTML do
use FormsWeb, :html
def today() do
Date.utc_today()
end
embed_templates "contact_html/*"
end
Like much of Phoenix and Elixir, files can generally go anywhere you like. In practice, grouping Controllers, Templates, and Components together is a good idea.
Reloading the page now produces almost the same error:
ArgumentError at GET /contact no “new” html template defined for FormsWeb.ContactHTML (the module exists but does not define new/1 nor render/2)
So add a file, lib/forms_web/controllers/contact_html/new.html.heex
:
<section>
<h1>Contact <%= today() %></h1>
</section>
Note how the function today/0
defined in ContactHTML
is available.
Reload the page if needed, and you’ll see today’s date. Excellent!
Note that it’s possible to include the heex
format template directly
in our view module just above, as a function, wrapped in the ~H
sigil:
...
def new(assigns) do
~H"""
<section>
<h1>Contact <%= today() %></h1>
</section>
"""
end
The next stage is to add a simple form control, and a button, to the
template in contact_html/new.html.heex
, but we will use this chance
to introduce another of Phoenix’s helpful tools to encapsulate things.
TODO
- explain links, validated paths, assigns syntax
- Generators