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:

  1. 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
  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 & 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).