class AddSlugToArticles < ActiveRecord::Migration[7.1]
def change
add_column :articles, :slug, :string
add_index :articles, :slug, unique: true
reversible do |dir|
dir.up do
Article.reset_column_information
Article.where(slug: nil).find_each do |article|
article.send(:set_slug)
article.update_column(:slug, article.slug)
end
end
end
end
end
module Sluggable
extend ActiveSupport::Concern
included do
class_attribute :slug_source_column, default: :title
before_validation :set_slug
end
class_methods do
def slug_from(column)
self.slug_source_column = column.to_sym
end
end
def to_param
slug.presence || super
end
private
def set_slug
return if slug.present? && !source_changed?
candidate = slug_candidate
self.slug = generate_unique_slug(candidate) if candidate.present?
end
def source_changed?
changed.include?(slug_source_column.to_s)
end
def slug_candidate
public_send(slug_source_column).to_s.parameterize
end
def generate_unique_slug(base)
slug = base
scope = self.class.where.not(id: id)
while scope.exists?(slug: slug)
slug = "#{base}-#{SecureRandom.hex(3)}"
end
slug
end
end
class Article < ApplicationRecord
include Sluggable
slug_from :title
belongs_to :author, class_name: "User"
has_many :comments, dependent: :destroy
validates :title, presence: true, length: { maximum: 160 }
validates :slug, presence: true, uniqueness: true
scope :published, -> { where.not(published_at: nil) }
def published?
published_at.present?
end
end
class ArticlesController < ApplicationController
before_action :set_article, only: %i[show edit update destroy]
def show
end
def create
@article = current_user.articles.build(article_params)
if @article.save
redirect_to @article, notice: "Article created."
else
render :new, status: :unprocessable_entity
end
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.published.find_by!(slug: params[:id])
end
def article_params
params.require(:article).permit(:title, :body, :published_at)
end
end
This snippet shows a self-contained approach to friendly URL slugs in Rails without pulling in the friendly_id gem, favoring a small, auditable concern instead. The pattern turns a human-readable title into a stable, URL-safe slug that is generated automatically, guaranteed unique, and usable directly in routes via to_param.
The add_slug.rb (migration) tab sets up the storage: a string column plus a unique index. The uniqueness constraint at the database level is the real safety net — application-level validation can race under concurrency, so the index is what ultimately prevents two records from sharing a slug. It is written as a partial-friendly plain unique index here for portability.
The Sluggable concern tab holds the logic. Including it wires up a before_validation callback, set_slug, that only fills the slug when it is blank or when the source attribute changed, so hand-edited slugs and existing rows are left alone. slug_candidate parameterizes the source text, and generate_unique_slug appends a short random suffix if the base slug already exists, looping until it finds a free value. This candidate-and-check loop is the same core idea friendly_id uses. The concern also overrides to_param so link_to article and article_path(article) emit the slug instead of the numeric id, and it centralizes the source column through slug_source_column so different models can slug off different fields.
The Article model tab shows how thin the model stays: it includes Sluggable, declares its source column, and validates presence and uniqueness of slug. The uniqueness validation mostly produces friendly errors; the DB index handles true races.
The ArticlesController tab demonstrates the finder side. Using find_by!(slug: params[:id]) means routes keep working with /articles/:id while resolving by slug, and find_by! raises RecordNotFound so Rails returns a proper 404. A key pitfall this design accepts is that changing a title changes the slug, which can break old links; teams that care about permalinks typically freeze the slug after first save or keep a history table. For most content this trade-off is acceptable and the implementation stays small and dependency-free.
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.