Use Slugs instead of IDs
Tags: phoenix, howto, webdev, elixir
Published: 2022-02-20 00:38:46

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.
```elixir
  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.
```elixir
  @derive {Phoenix.Param, key: :slug}
```
Read more about [Phoenix.Param](https://hexdocs.pm/phoenix/Phoenix.Param.html#module-custom-parameters) 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.
```elixir
  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.
```elixir
  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.
```elixir
  ...
  |> 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.
```elixir
  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](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 `id`s and `slug`s, it makes sense to adjust router with expected attribute name to avoid confusions.
```elixir
  # lib/fluxdash_web/live/organization_live/show.ex
  def handle_params(%{"slug" => slug}, _, socket) do
  ...
  end
  
  # routes.ex
  live "/organizations/:slug", OrganizationLive.Show, :show
```

PS. Originally posted as part of [Fluxdash Organizations](https://monobit.dev/blog/fluxdash-organizations-3-details/#use-organization-name-in-urls) series.