DEV Community

Dmitry Semenov
Dmitry Semenov

Posted on • Edited on • Originally published at monobit.dev

Use Slugs instead of IDs

While articles with the entire feature development process can be attractive to a couple of visitors, it appears that How-To's for a specific topic is way easier to find in the search engines. I'll keep posting project-related articles to share some product/discovery cases and focus more on guides for tech topics.

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)
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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 Fluxdash. I still want to enter fluxdash 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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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/fluxdash_web/live/organization_live/show.ex
  def handle_params(%{"slug" => slug}, _, socket) do
  ...
  end

  # routes.ex
  live "/organizations/:slug", OrganizationLive.Show, :show
Enter fullscreen mode Exit fullscreen mode

PS. Originally posted as part of Fluxdash Organizations series.

Top comments (0)