My changes to the default Phoenix boilerplate
An overview of some changes I make to Phoenix’s default project boilerplate generated by mix phx.new
when working on my own projects.
Page titles defined in HTML views
The default root layout generated by phx.new
uses assigns[:page_title]
with a fallback for page titles:
<title><%= assigns[:page_title] || "My App" %></title>
What I like to do instead is to define a title/2
function in my HTML views that takes i) the name of the current template and ii) assigns
, and returns the page title:
# lib/myapp_web/controllers/user_settings_html.ex
defmodule MyAppWeb.UserSettingsHTML do
use MyAppWeb, :html
embed_templates "user_settings_html/*"
def title("index.html", _assigns),
do: "Your settings"
def title("team.html", %{team: team}),
do: "Team settings for #{team.name}"
def title(_, _assigns),
do: "Settings | My app"
end
I call title/2
using a layouts helper:
# lib/myapp_web/components/layouts.ex
defmodule MyAppWeb.Layouts do
use MyAppWeb, :html
embed_templates "layouts/*"
def title(assigns) do
module = Phoenix.Controller.view_module(assigns.conn)
template = Phoenix.Controller.view_template(assigns.conn)
module.title(template, assigns)
end
end
And finally I call the helper from my root layout:
- <title><%= assigns[:page_title] || "My App" %></title>
+ <title><%= title(assigns) %></title>
Since there’s no fallback, if a HTML view module doesn’t define title/2
(or doesn’t handle all templates) then a runtime error will be raised.
Having meaningful page titles is not only good for UX but also for SEO, so I like how this method nudges devs to define a page title before working on their templates.
The downside of this approach is that there is a risk of introducing runtime errors in production if your unit tests don’t cover all user facing routes.
If that’s the case for your codebase, you can use function_exported?/3
to check for the presence of a title/2
function in your HTML view module:
# lib/myapp_web/components/layouts.ex
defmodule MyAppWeb.Layouts do
use MyAppWeb, :html
embed_templates "layouts/*"
def title(assigns) do
module = Phoenix.Controller.view_module(assigns.conn)
template = Phoenix.Controller.view_template(assigns.conn)
if function_exported?(module, :title, 2) do
module.title(template, assigns)
else
"My app"
end
end
end
EDIT 2025-03-06 - Heads up for Live View users: /u/Niicodemus on r/elixir pointed out that this approach does not work with Live View’s :navigate
and :patch
navigation:
FYI:
@page_title
is a special cased assign for LiveView, and your method for titling will not work with:navigate
or:patch
navigation in live view.
Queryable
I like to implement soft-deletes using a deleted_at
timestamp field on my models. The main drawback with this approach is that if you forget to add a WHERE deleted_at IS NULL
clause to your queries, your users will see deleted data.
Thankfully, there’s a nice macro-based solution to this explained by vereis’s post “Ecto Queries are just Data!”.
Here’s how it works:
- Create a
Queryable
module:
defmodule MyApp.Queryable do
defmacro __using__(_opts \\ []) do
quote do
use Ecto.Schema
import Ecto.{Changeset, Query}
import unquote(__MODULE__)
@behaviour unquote(__MODULE__)
@impl unquote(__MODULE__)
def base_query(),
do: from(x in __MODULE__, as: :self)
@impl unquote(__MODULE__)
def query(base_query \\ base_query(), filters) do
Enum.reduce(filters, base_query, fn {field, value}, query ->
apply_filter(query, field, value)
end)
end
defoverridable query: 1, query: 2, base_query: 0
end
end
import Ecto.Query
@callback base_query() :: Ecto.Queryable.t()
@callback query(base_query :: Ecto.Queryable.t(), opts :: Keyword.t()) :: Ecto.Queryable.t()
@optional_callbacks base_query: 0
@spec apply_filter(Ecto.Queryable.t(), field :: atom, value :: any) :: Ecto.Queryable.t()
def apply_filter(query, field, value) when is_list(value),
do: from(x in query, where: field(x, ^field) in ^value)
def apply_filter(query, field, value),
do: from(x in query, where: field(x, ^field) == ^value)
end
- In your models, replace
use Ecto.Schema
withuse MyApp.Queryable
. For models with a soft-deletion field, create abase_query/0
that excludes deleted rows.
defmodule MyApp.Accounts.User do
use MyApp.Queryable
alias __MODULE__
schema "users" do
# ...
field :deleted_at, :utc_datetime
timestamps(type: :utc_datetime)
end
@impl MyApp.Queryable
def base_query(),
do: from(u in User, as: :user, where: is_nil(u.deleted_at))
def archived_query(),
do: from(u in User, as: :user, where: not is_nil(u.deleted_at))
def include_archived_query(),
do: from(u in User, as: :user)
end
- Use your model’s
query/2
function to build Ecto queries:
def get_user_by_email(email) when is_binary(email) do
# Equivalent to `User.query(User.base_query(), email: email)`,
# so does not include soft-deleted rows
User.query(email: email)
|> Repo.one()
end
def get_archived_user_by_email(email) when is_binary(email) do
User.archived_query()
|> User.query(email: email)
|> Repo.one()
end
You’ll probably want to extend Queryable
with some additional apply_filter
:
def apply_filter(query, :preload, value),
do: from(x in query, preload: ^value)
def apply_filter(query, :limit, value),
do: from(x in query, limit: ^value)
def apply_filter(query, :offset, value),
do: from(x in query, offset: ^value)
def apply_filter(query, :order_by, value),
do: from(x in query, order_by: ^value)
# Ecto only allows literal strings for locks
def apply_filter(query, :lock, "FOR UPDATE"),
do: from(x in query, lock: "FOR UPDATE")
Prefixed & human readable object IDs
For my models’ primary IDs, I use UUIDv4, which look something like:
4c4a82ed-a3e1-4c56-aa0a-26962ddd0425
To make them a bit more friendly for humans as well as to make debugging easier, I convert UUIDs to object IDs (aka Stripe IDs) after fetching them from the database.
Object IDs look like:
pi_3LKQhvGUcADgqoEM3bh6pslE
They’re prefixed so you know which table they belong to (which makes debugging easier, since with UUIDs you’d need to query across all tables in your database to figure out where they came from) and by being base62 encoded they make eyeballing differences easier.
The great thing about Ecto
is that you can use an Ecto.ParameterizedType
to make converting from raw UUIDs to object IDs automatic.
I use the approach described in Dan Schultzer’s post “Prefixed base62 UUIDv7 Object IDs with Ecto”, with the main change being using Ecto.UUID
instead of Uniq.UUID
since I didn’t need UUIDv7 support:
defmodule MyApp.PrefixedUUID do
@doc """
Generates prefixed base62 encoded UUIDs (v4).
## Examples
@primary_key {:id, MyApp.PrefixedUUID, prefix: "user", autogenerate: true}
@foreign_key_type MyApp.PrefixedUUID
"""
use Ecto.ParameterizedType
@impl Ecto.ParameterizedType
def init(opts) do
schema = Keyword.fetch!(opts, :schema)
field = Keyword.fetch!(opts, :field)
case opts[:primary_key] do
true ->
prefix = Keyword.get(opts, :prefix) || raise "`:prefix` option is required"
%{
primary_key: true,
schema: schema,
prefix: prefix
}
_any ->
%{
schema: schema,
field: field
}
end
end
@impl Ecto.ParameterizedType
def type(_params),
do: :uuid
@impl Ecto.ParameterizedType
# Handle nil in order to support optional `belongs_to` fields
def cast(nil, _params),
do: {:ok, nil}
def cast(data, params) do
with {:ok, prefix, _uuid} <- slug_to_uuid(data, params),
{prefix, prefix} <- {prefix, prefix(params)} do
{:ok, data}
else
_ -> :error
end
end
defp slug_to_uuid(string, _params) do
with [prefix, slug] <- String.split(string, "_"),
{:ok, uuid} <- decode_base62_uuid(slug) do
{:ok, prefix, uuid}
else
_ -> :error
end
end
defp prefix(%{primary_key: true, prefix: prefix}),
do: prefix
# If we deal with a belongs_to assocation we need to fetch the prefix from
# the association's schema module
defp prefix(%{schema: schema, field: field}) do
%{related: schema, related_key: field} = schema.__schema__(:association, field)
{:parameterized, {__MODULE__, %{prefix: prefix}}} = schema.__schema__(:type, field)
prefix
end
@impl Ecto.ParameterizedType
def load(nil, _loader, _params),
do: {:ok, nil}
def load(data, _loader, params) do
case Ecto.UUID.load(data) do
{:ok, uuid} -> {:ok, uuid_to_slug(uuid, params)}
:error -> :error
end
end
defp uuid_to_slug(uuid, params) do
"#{prefix(params)}_#{encode_base62_uuid(uuid)}"
end
@impl Ecto.ParameterizedType
def dump(nil, _dumper, _params),
do: {:ok, nil}
def dump(slug, _dumper, params) do
case slug_to_uuid(slug, params) do
{:ok, _prefix, uuid} -> Ecto.UUID.dump(uuid)
:error -> :error
end
end
@impl Ecto.ParameterizedType
def autogenerate(params) do
Ecto.UUID.generate()
|> uuid_to_slug(params)
end
@impl Ecto.ParameterizedType
def embed_as(format, _params),
do: Ecto.UUID.embed_as(format)
@impl Ecto.ParameterizedType
def equal?(a, b, _params),
do: Ecto.UUID.equal?(a, b)
# UUID Base62 encoder/decoder
@base62_uuid_length 22
@uuid_length 32
# No need for `String.downcase(uuid)` here as `String.to_integer(16)` takes care of that for us
defp encode_base62_uuid(uuid) do
uuid
|> String.replace("-", "")
|> String.to_integer(16)
|> base62_encode()
|> String.pad_leading(@base62_uuid_length, "0")
end
defp decode_base62_uuid(string) do
with {:ok, number} <- base62_decode(string) do
number_to_uuid(number)
end
end
defp number_to_uuid(number) do
number
|> Integer.to_string(16)
|> String.downcase()
|> String.pad_leading(@uuid_length, "0")
|> case do
<<g1::binary-size(8), g2::binary-size(4), g3::binary-size(4), g4::binary-size(4),
g5::binary-size(12)>> ->
{:ok, "#{g1}-#{g2}-#{g3}-#{g4}-#{g5}"}
other ->
{:error, "got invalid base62 uuid; #{inspect(other)}"}
end
end
# Base62 encoder/decoder
@base62_alphabet ~c"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
for {digit, idx} <- Enum.with_index(@base62_alphabet) do
defp base62_encode(unquote(idx)), do: unquote(<<digit>>)
end
defp base62_encode(number) do
base62_encode(div(number, unquote(length(@base62_alphabet)))) <>
base62_encode(rem(number, unquote(length(@base62_alphabet))))
end
defp base62_decode(string) do
string
|> String.split("", trim: true)
|> Enum.reverse()
|> Enum.reduce_while({:ok, {0, 0}}, fn char, {:ok, {acc, step}} ->
case decode_base62_char(char) do
{:ok, number} ->
{:cont,
{:ok, {acc + number * Integer.pow(unquote(length(@base62_alphabet)), step), step + 1}}}
{:error, error} ->
{:halt, {:error, error}}
end
end)
|> case do
{:ok, {number, _step}} -> {:ok, number}
{:error, error} -> {:error, error}
end
end
for {digit, idx} <- Enum.with_index(@base62_alphabet) do
defp decode_base62_char(unquote(<<digit>>)), do: {:ok, unquote(idx)}
end
defp decode_base62_char(char),
do: {:error, "got invalid base62 character; #{inspect(char)}"}
end
Expand to see unit tests
defmodule MyApp.PrefixedUUIDTest do
use MyApp.DataCase, async: true
alias MyApp.PrefixedUUID
alias Uniq.UUID
defmodule TestSchema do
use Ecto.Schema
@primary_key {:id, PrefixedUUID, prefix: "test", autogenerate: true}
@foreign_key_type PrefixedUUID
schema "test" do
belongs_to :test, TestSchema
end
end
@params PrefixedUUID.init(
schema: TestSchema,
field: :id,
primary_key: true,
autogenerate: true,
prefix: "test"
)
@belongs_to_params PrefixedUUID.init(schema: TestSchema, field: :test, foreign_key: :test_id)
@loader nil
@dumper nil
@test_prefixed_uuid "test_1ynaXj7PgyI2ILwITilfKP"
@test_uuid UUID.to_string("4113e2f2-fd9f-4db4-951e-3cf90c1a4bbd", :raw)
@test_prefixed_uuid_with_leading_zero "test_0IHNj4hnEkWEijgtIfRkUg"
@test_uuid_with_leading_zero UUID.to_string("09b00af0-2a9b-4a30-9e8c-de64bbe2805e", :raw)
@test_prefixed_uuid_null "test_0000000000000000000000"
@test_uuid_null UUID.to_string("00000000-0000-0000-0000-000000000000", :raw)
@test_prefixed_uuid_invalid_characters "test_" <> String.duplicate(".", 32)
@test_uuid_invalid_characters String.duplicate(".", 22)
@test_prefixed_uuid_invalid_format "test_" <> String.duplicate("x", 31)
@test_uuid_invalid_format String.duplicate("x", 21)
test "cast/2" do
assert PrefixedUUID.cast(@test_prefixed_uuid, @params) == {:ok, @test_prefixed_uuid}
assert PrefixedUUID.cast(@test_prefixed_uuid_with_leading_zero, @params) ==
{:ok, @test_prefixed_uuid_with_leading_zero}
assert PrefixedUUID.cast(@test_prefixed_uuid_null, @params) == {:ok, @test_prefixed_uuid_null}
assert PrefixedUUID.cast(nil, @params) == {:ok, nil}
assert PrefixedUUID.cast("otherprefix" <> @test_prefixed_uuid, @params) == :error
assert PrefixedUUID.cast(@test_prefixed_uuid_invalid_characters, @params) == :error
assert PrefixedUUID.cast(@test_prefixed_uuid_invalid_format, @params) == :error
assert PrefixedUUID.cast(@test_prefixed_uuid, @belongs_to_params) ==
{:ok, @test_prefixed_uuid}
end
test "load/3" do
assert PrefixedUUID.load(@test_uuid, @loader, @params) == {:ok, @test_prefixed_uuid}
assert PrefixedUUID.load(@test_uuid_with_leading_zero, @loader, @params) ==
{:ok, @test_prefixed_uuid_with_leading_zero}
assert PrefixedUUID.load(@test_uuid_null, @loader, @params) == {:ok, @test_prefixed_uuid_null}
assert PrefixedUUID.load(@test_uuid_invalid_characters, @loader, @params) == :error
assert PrefixedUUID.load(@test_uuid_invalid_format, @loader, @params) == :error
assert PrefixedUUID.load(@test_prefixed_uuid, @loader, @params) == :error
assert PrefixedUUID.load(nil, @loader, @params) == {:ok, nil}
assert PrefixedUUID.load(@test_uuid, @loader, @belongs_to_params) ==
{:ok, @test_prefixed_uuid}
end
test "dump/3" do
assert PrefixedUUID.dump(@test_prefixed_uuid, @dumper, @params) == {:ok, @test_uuid}
assert PrefixedUUID.dump(@test_prefixed_uuid_with_leading_zero, @dumper, @params) ==
{:ok, @test_uuid_with_leading_zero}
assert PrefixedUUID.dump(@test_prefixed_uuid_null, @dumper, @params) == {:ok, @test_uuid_null}
assert PrefixedUUID.dump(@test_uuid, @dumper, @params) == :error
assert PrefixedUUID.dump(nil, @dumper, @params) == {:ok, nil}
assert PrefixedUUID.dump(@test_prefixed_uuid, @dumper, @belongs_to_params) ==
{:ok, @test_uuid}
end
test "autogenerate/1" do
assert prefixed_uuid = PrefixedUUID.autogenerate(@params)
assert {:ok, uuid} = PrefixedUUID.dump(prefixed_uuid, nil, @params)
assert {:ok, %UUID{format: :raw, version: 4}} = UUID.parse(uuid)
end
end
To use this custom type in your schemas, just use the @primary_key
and @foreign_key_type
module attributes:
@primary_key {:id, MyApp.PrefixedUUID, prefix: "user", autogenerate: true}
@foreign_key_type MyApp.PrefixedUUID
schema "users" do
field :name, :string
# ...
When fetching your structs, their IDs will automatically be in object ID form, and trying to use an ID with the wrong prefix will raise an error:
iex(1)> user = MyApp.Repo.get(MyApp.Accounts.User, "user_2Vc9LiNYb4itcWAeH46W4v")
#MyApp.Accounts.User<
__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
id: "user_2Vc9LiNYb4itcWAeH46W4v",
...
>
iex(2)> user = MyApp.Repo.get(MyApp.Accounts.User, "user2_1ynaXj7PgyI2ILwITilfKP")
** (Ecto.Query.CastError) deps/ecto/lib/ecto/repo/queryable.ex:477: value `"user2_1ynaXj7PgyI2ILwITilfKP"` in `where` cannot be cast to type #MyApp.PrefixedUUID<%{prefix: "user", schema: MyApp.Accounts.User, primary_key: true}> in query:
from u0 in MyApp.Accounts.User,
where: u0.id == ^"user2_1ynaXj7PgyI2ILwITilfKP",
select: u0
(elixir 1.18.2) lib/enum.ex:2546: Enum."-reduce/3-lists^foldl/2-0-"/3
(elixir 1.18.2) lib/enum.ex:1840: Enum."-map_reduce/3-lists^mapfoldl/2-0-"/3
(elixir 1.18.2) lib/enum.ex:2546: Enum."-reduce/3-lists^foldl/2-0-"/3
(ecto 3.12.5) lib/ecto/repo/queryable.ex:214: Ecto.Repo.Queryable.execute/4
(ecto 3.12.5) lib/ecto/repo/queryable.ex:19: Ecto.Repo.Queryable.all/3
(ecto 3.12.5) lib/ecto/repo/queryable.ex:154: Ecto.Repo.Queryable.one/3
iex:2: (file)
Render emails using heex components
To render HTML emails, I use components as described by this José Valim GitHub comment and Andrew Stewart’s post “Phoenix email defaults: better templates using components”:
defmodule MyApp.MailNotifier do
use MyAppWeb, :html
require Logger
alias MyApp.Mailer
def deliver_confirmation_instructions(user, url) do
assigns = %{user: user, url: url}
html_body = ~H"""
<.email_layout>
<p>Hi <%= @user.name %>,</p>
<p>You can confirm your account by visiting the link below:</p>
<p><a href={@url}><%= @url %></a></p>
<p>(If you didn't create an account with us, please ignore this email.)</p>
</.email_layout>
"""
text_body = """
Hi #{user.name},
You can confirm your account by visiting the link below:
#{url}
(If you didn't create an account with us, please ignore this email.)
"""
deliver(user.email, "Confirmation instructions", html_body, text_body)
end
defp email_layout(assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
body {
font-family: system-ui, sans-serif;
margin: 3em auto;
overflow-wrap: break-word;
word-break: break-all;
max-width: 1024px;
padding: 0 1em;
}
</style>
</head>
<body>
<%= render_slot(@inner_block) %>
</body>
</html>
"""
end
defp deliver(recipient, subject, html_body, text_body) do
html_body =
html_body
|> Phoenix.HTML.html_escape()
|> Phoenix.HTML.safe_to_string()
email =
Swoosh.Email.new(
to: recipient,
from: {"MyApp", "support@example.com"},
subject: subject,
html_body: html_body,
text_body: text_body
)
case Mailer.deliver(email) do
{:ok, _metadata} ->
{:ok, email}
{:error, reason} ->
Logger.warning("Sending email failed: #{inspect(reason)}")
{:error, reason}
end
end
end
I’m defining plain text emails’ content explicitly instead of using Andrew’s Floki.text(sep: "\n\n")
approach as I found Floki was inserting too many newlines (e.g. when a link was nested within a paragraph).