My changes to the default Phoenix boilerplate
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>
The problem with this approach is that it’s easy to forget to pass page_title: "My page title"
to render
or assign
in your controller.
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/page_html.ex
defmodule MyAppWeb.PageHTML do
use MyAppWeb, :html
embed_templates "page_html/*"
def title("home.html", _assigns),
do: "My app: the best way to foobar"
def title("contact.html", _assigns),
do: "Contact us"
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 fallbacks, if a HTML view doesn’t define title/2
or doesn’t handle all templates then a runtime error will be raised.
There shouldn’t be any risk of introducing runtime errors in production since even if a dev forgets to manually check their template during development and realise they’re missing def title("new_template.html", _)
, your unit tests should catch it.
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:
- Define your
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 base62 IDs
For my models’ primary IDs, I use UUIDv4. To make them a bit more friendly for humans as well as to make debugging easier, I convert them to prefixed base62 form (aka object IDs) using Ecto.ParameterizedType
.
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
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
# ...
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).