class AddSlugToArticles < ActiveRecord::Migration[7.1]
def change
add_column :articles, :slug, :string, null: false, default: ""
add_index :articles, :slug, unique: true
# Backfill existing rows before the unique index is relied upon in code.
reversible do |dir|
dir.up do
Article.reset_column_information
Article.find_each do |article|
article.update_column(:slug, article.send(:slugify, article.title))
end
end
end
end
end
class Article < ApplicationRecord
validates :title, presence: true
validates :slug, presence: true, uniqueness: true,
format: { with: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ }
before_validation :assign_slug, if: :assign_slug?
def self.create_with_slug(attributes)
article = new(attributes)
attempts = 0
begin
article.save!
rescue ActiveRecord::RecordNotUnique
attempts += 1
raise if attempts > 3
article.send(:assign_slug)
retry
end
article
end
def to_param
slug
end
private
def assign_slug?
new_record? || will_save_change_to_title?
end
def assign_slug
self.slug = ensure_unique_slug(slugify(title))
end
def slugify(value)
value.to_s.parameterize
end
def ensure_unique_slug(base)
candidate = base
suffix = 1
while self.class.where.not(id: id).exists?(slug: candidate)
suffix += 1
candidate = "#{base}-#{suffix}"
end
candidate
end
end
class ArticlesController < ApplicationController
before_action :set_article, only: %i[show edit update destroy]
def show
end
def create
@article = Article.create_with_slug(article_params)
redirect_to @article, notice: "Article published."
rescue ActiveRecord::RecordInvalid => e
@article = e.record
render :new, status: :unprocessable_entity
end
def update
if @article.update(article_params)
redirect_to @article, notice: "Article updated."
else
render :edit, status: :unprocessable_entity
end
end
private
def set_article
@article = Article.find_by!(slug: params[:slug])
end
def article_params
params.require(:article).permit(:title, :body)
end
end
This snippet shows the common Rails pattern for giving records a stable, human-readable URL slug that is guaranteed unique, without pulling in a gem. It spans three collaborating files: the migration that shapes the column, the model that fills it in, and the controller that reads it back.
In db/migrate slug migration, the slug is treated as first-class data rather than a derived string. The column is null: false and backed by a unique index, so the database itself is the final arbiter of uniqueness. Enforcing this at the storage layer matters because application-level checks alone are racy: two concurrent inserts can both pass an ActiveRecord uniqueness validation and then collide. The unique: true index turns that race into a catchable RecordNotUnique rather than duplicate rows.
Article model builds the slug in a before_validation callback, assign_slug, that only fires on: :create or when the title changes, so editing unrelated fields never churns the URL. The slugify helper parameterizes the title into a lowercase, hyphenated base. ensure_unique_slug then probes the table with where.not(id: id) — excluding the current record so re-saving is a no-op — and appends -2, -3, and so on until it finds a free value. Doing this in before_validation means the presence and uniqueness validators see the final slug, giving clean error messages instead of a raw database exception in the normal case.
Because a generated suffix can still lose a race under real concurrency, the create_with_slug class method wraps the save and retries on ActiveRecord::RecordNotUnique, regenerating the slug on the next attempt. This combines the friendly UX of pre-computed slugs with the hard guarantee of the unique index — the belt-and-suspenders approach worth reaching for whenever a URL segment must be both readable and collision-free.
ArticlesController closes the loop. Its routes use :slug as the identifier, so set_article calls find_by!(slug: params[:slug]), which raises RecordNotFound and yields a proper 404 for unknown slugs. Overriding to_param in the model keeps article_path(@article) emitting the slug automatically, so links and lookups stay in sync without manual URL building anywhere in the app.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Semantic HTML Example</title>
Semantic HTML5 elements and accessibility best practices
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
Share this code
Here's the card — post it anywhere.