Adding slugs to your model in Ruby on Rails

18 December 2018 at 12:53 - 5 minute read

To begin, let's do a little review. Right now your model uses id as the parameter. You'll see :id in your tests and in routes.rb. But let's say we want to change that to a slug, so the url in the browser is more meaningful (apparently this also helps with search ranking, though I haven't verified that)? In my case, I'd like to update the Post model, and I'd like to use a combination of the post id and the post title instead - all downcase, with hypens in place of spaces.

First let's change our posts_controller_spec and posts_routing_spec:

In spec/controllers/posts_controller_spec.rb, anywhere that we have something like:

get :show, params: {id: post.to_param}, session: valid_session

We'll change that to:

get :show, params: {slug: post.to_param}, session: valid_session

In this case we can just do a find and replace all, replacing id: with slug: (careful in case you have something like user_id: in there). Pretty easy. Similarly, for the routing test, we'll do a find and replace on :id and turn it into :slug, turning:

expect(:get => "/posts/1/edit").to route_to("posts#edit", :id => "1")

into

expect(:get => "/posts/1/edit").to route_to("posts#edit", :slug => "1")

Now let's get to the changes. First let's do a couple of updates to app/models/post.rb:

class Post < ApplicationRecord
  ...
  before_create :set_slug

  def to_param
    slug
  end

  private

  def set_slug
    Post.last ? next_id = (Post.last.id + 1).to_s : next_id = "1"
    if slug.blank?
      self.slug = next_id + "-" + title.downcase.strip.gsub(/\s+/, "-")
    end
  end
  ...
end

Setting #to_param is necessary in order to maintain the functionality of Rails features like form_for. The actual functionality of #set_slug can take any number of forms; you can generate unique uuids, use another field here, just use the title or other unique field (without the id), or allow the user to set the field directly (in which case, you don't need to use set_slug at all).

In config/routes.rb, we need to change:

resources :posts

to

resources :posts, param: :slug

This tells rails to read the params as :slug instead of :id. Which means we have to change the controller as well. In app/controllers/posts_controller.rb, we'll make sure set_post uses the slug:

class PostsController < ApplicationController
  before_action :set_post, only: [:show, :edit, :update, :destroy]

  ...

  def set_post
    @post = Post.find_by_slug(params[:slug])
  end
  ...
end

Of course, we're missing a big piece here: the migration! We need to actually create the column and populate it for existing Posts. We can do this all at once:

class AddSlugToPosts < ActiveRecord::Migration[5.2]
  def change
    add_column :posts, :slug, :string
    Post.all.each do |p|
      p.slug = p.id.to_s + "-" + p.title.downcase.strip.gsub(/\s+/, "-")
      p.save
    end
    add_index :posts, :slug, unique: true
    change_column :posts, :slug, :string, :null => false
  end
end

Let's break this down. We're creating the column. Then, we're creating the slug for each existing Post and saving it. Why? Because we want to set null: false and add an index on this column, but can't do so until existing Posts have values in that column. After we do this, we can add the index and change the column with :null => false. Run the migration with rails db:migrate. Verify that your tests pass and we're done.

p.s. an alternative to this is using Friendly Id, but that seems like overkill for a small blog (and less fun).

← Using Markdown in your blog with Ruby on R...
Adding Graphql to a React on Rails Applica... →