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:

  1. 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
  1. In your models, replace use Ecto.Schema with use MyApp.Queryable. For models with a soft-deletion field, create a base_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
  1. 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).