Creating Phoenix Beginner

Roberto ·

Phoenix Beginner is, of course, running on Phoenix! I've outlined how I created the website so that you can (hopefully!) glean insight from the process when starting your own Phoenix project.

At its heart, Phoenix Beginner is a hand-rolled content management system (CMS).

Follow along to recreate the basic setup I have here which can be repurposed for your own blog or website.

Scaffolding

Let's start by getting the latest version of Phoenix:

mix local.phx

Create a new Phoenix project:

mix phx.new phoenixbeginner --module PhoenixBeginner --app phoenix_beginner

We're going to need user accounts to set up a private writing area. Luckily, Phoenix comes with an authentication generator out of the box:

mix phx.gen.auth Accounts User users

We'll get back to authentication in a bit.

Hide the number of records

Most projects obscure the number of records that they have by using a UUID or a Hashid to replace the auto-increment id column provided by most databases. While Phoenix Beginner will have very few records to begin with and most of them will be public in any case, it's always nice to be set up for that extra bit of privacy later on.

My personal preference is to use a Snowflake ID, which is overkill for most projects. Skip this or use a Hashid instead unless you're also partial to Snowflakes.

Add Snowflake.ex

Let's modify our mix.exs dependencies to include a Snowflake generator

def deps do
  [{:snowflake, "~> 1.0.0"}]
end

Next we need to set the Snowflake epoch in milliseconds, which we can do by fetching a UTC timestamp from https://www.epochconverter.com/ such as 1667828220000 (the date I started this project).

Modify config.exs:

config :snowflake,
  machine_id: 0,
  epoch: 1667828220000 # 2022-11-07 13:37:00Z

Let's start using the new Snowflake ID. I open up lib/phoenix_beginner_accounts/user.ex and change registration_changeset/3:

  def registration_changeset(user, attrs, opts \\ []) do
    user
    |> cast(attrs, [:email, :password])
    |> validate_email(opts)
    |> validate_password(opts)
    |> put_change(:id, PhoenixBeginner.Utility.generate_id())
  end

You'll notice I've set up a Utility module which wraps around the Snowflake id generator:

defmodule PhoenixBeginner.Utility do
   def generate_id() do
      case Snowflake.next_id() do
         {:ok, id} -> id
         _ -> raise "ID creation error."
      end
   end
end

Authentication

The Phoenix generator command we ran earlier gave us a working authentication system. However, there's no need to allow public registration at this time, so I'm going to go ahead and disable that in lib/phoenix-beginner_web/live/user_registration_live.ex:

  def handle_event("save", %{"user" => user_params}, socket) do
    {:noreply,
      socket
      |> put_flash(:error, "Registration currently disabled.")
    }

    # Disabled for the time being:
    # case Accounts.register_user(user_params) do
    #   {:ok, user} ->
    #     {:ok, _} =
    #       Accounts.deliver_user_confirmation_instructions(
    #         user,
    #         &url(~p"/users/confirm/#{&1}")
    #       )
    #
    #     changeset = Accounts.change_user_registration(user)
    #     {:noreply, assign(socket, trigger_submit: true, changeset: changeset)}
    #
    #   {:error, %Ecto.Changeset{} = changeset} ->
    #     {:noreply, assign(socket, :changeset, changeset)}
    # end
  end

Authorisation

Even though I've disabled registration, I know we'll be needing it back in the future once we have editors, authors and collaborators. When that happens everyone should have a different set of permissions. We can do this with a very simple role-based access control setup (RBAC).

Create the database migration

The first order of business is to generate a Role migration file:

defmodule PhoenixBeginner.Repo.Migrations.CreateRoles do
  use Ecto.Migration

  def change do
    create table(:roles) do
      add :name, :string, null: false
      add :description, :text

      timestamps()
    end

    create unique_index(:roles, :name)
  end
end

Create the Role schema

Notice we're using string for our description while in the migration it was text. This is because the same Ecto type supports multiple database types.

defmodule PhoenixBeginner.Accounts.Role do
  use Ecto.Schema
  import Ecto.Changeset

  schema "roles" do
    field :name, :string
    field :description, :string

    timestamps()
  end

  @doc false
  def changeset(role, attrs) do
    role
    |> cast(attrs, [:name, :description])
    |> validate_required([:name])
    |> validate_length(:name, min: 3, max: 100)
  end
end

Add Pages

Set up public routes

Set up admin routes

Create the authoring experience

Set up the form

Add TinyMCE

Create the reading experience

Sanitise the output

Use components

Add breadcrumbs

Add Prism

Style the time

Or should we say formatting?

Add a favicon

Deploy

Test the release locally

Choose a hosting provider

Deployment steps