Phoenix How-To: Use Slugs instead of IDs

4m read

This time let’s talk about slugs in the Phoenix framework. Slug is the part of the URL that uses human-readable keywords to identify a page. Slug is the first thing you want to support for blog post URLs, public profiles, FAQ articles, etc. Generators of the Phoenix framework use IDs by default, and you have to let the framework know about your intention to use slugs instead. So let’s see the steps required to use slugs instead of IDs in your URL.

Basic slug support

First, define a method the app will use to search your objects using the slug, name, title, or any other field of your choice. You can put this near get_organization! method that the CRUD generator created for you.

  def get_by_slug!(slug), do: Repo.get_by!(Organization, slug: slug)

Now replace get_organization! calls in all live/organizations methods that you’ll find (show, edit, delete actions should use this to find an organization by id) with brand new get_by_slug! call. It won’t work yet since we need to tell Phoenix that we changed a key parameter from id to slug. Add this at the top of your organizations schema to achieve this.

  @derive {Phoenix.Param, key: :slug}

Read more about Phoenix.Param protocol here.

We have covered the happy path scenario, where users click URLs generated by the framework, but we should also consider users who can type URLs in their browser window.

Handle MixedCase uniqueness

My organization slug is Monobit. I still want to enter monobit and get the same results. We also wish to treat slugs’ uniqueness despite CaseSelection. To get this done, we need to create a new unique index that will apply the lower() function to the passed slug. Let’s generate new migration for this.

  mix ecto.gen.migration add_organization_slug_unique_lower_index
  ...
  create index(:organizations, ["lower(slug)"], unique: true)

If you’re here from the first post where we created the organization’s table, you should also drop the unique index that we added at the beginning.

  drop_if_exists index("organizations", [:slug], slug: :organizations_slug_index)

Now, as we also got a new index slug, we must reflect that in our constraint validation in the changeset; let’s put the new index slug here.

  ...
  |> unique_constraint(:slug, slug: :organizations_lower_slug_index)

And now we should update our get_by_slug! function to use the index, and downcase input string to perform case agnostic searches.

  def get_by_slug!(slug), do: Repo.one!(from o in Organization, where: fragment("lower(?)", o.slug) == ^String.downcase(slug))

Now we can use show/edit/delete with organization slugs instead of IDs.

If you need to generate slugs for more complex scenarios, such as blog titles without strict format requirements, take a look at the https://github.com/sobolevn/ecto_autoslug_field library, which can generate slugs based on the existing field.

Update routes to use slug param name

If you have both types of routes, with ids and slugs, it makes sense to adjust router with expected attribute name to avoid confusions.

  # lib/monobit_web/live/organization_live/show.ex
  def handle_params(%{"slug" => slug}, _, socket) do
  ...
  end
  
  # routes.ex
  live "/organizations/:slug", OrganizationLive.Show, :show