Controller actions often share common logic: authentication checks, loading resources, setting headers, or logging requests. Scattering this code across every action creates duplication and maintenance headaches. Rails 8 action callbacks solve this elegantly.
Understanding the Callback Chain
Action callbacks run at specific points in the request lifecycle. Rails provides three types:
- before_action - Runs before the action method executes
- after_action - Runs after the action completes (but before response is sent)
- around_action - Wraps the action, running code before and after
The callback chain executes in order of definition. If any before_action halts the chain (by rendering or redirecting), subsequent callbacks and the action itself never run.
Before Actions: The Workhorses
Most controller callbacks are before_action filters. They handle authentication, authorization, and resource loading.
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
before_action :authenticate_user!
before_action :set_article, only: %i[show edit update destroy]
before_action :authorize_article, only: %i[edit update destroy]
def index
@articles = Article.published.recent
end
def show
# @article already loaded by set_article
end
def edit
# @article loaded and authorized
end
def update
if @article.update(article_params)
redirect_to @article, notice: "Article updated."
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@article.destroy
redirect_to articles_path, notice: "Article deleted."
end
private
def set_article
@article = Article.find(params[:id])
end
def authorize_article
unless @article.editable_by?(Current.user)
redirect_to @article, alert: "Not authorized."
end
end
def article_params
params.require(:article).permit(:title, :body, :published_at)
end
endThe only: and except: options control which actions trigger the callback. Use only: when the callback applies to few actions; use except: when it applies to most.
Halting the Callback Chain
Callbacks can halt execution by calling render, redirect_to, or head. This prevents the action from running—useful for authentication and authorization.
# app/controllers/concerns/api_authentication.rb
module ApiAuthentication
extend ActiveSupport::Concern
included do
before_action :authenticate_api_request
end
private
def authenticate_api_request
token = request.headers["Authorization"]&.remove("Bearer ")
@current_api_user = User.find_by_api_token(token)
unless @current_api_user
render json: { error: "Invalid or missing API token" }, status: :unauthorized
# Chain halts here - action never executes
end
end
def current_api_user
@current_api_user
end
endWhen render executes in a callback, Rails sets a flag indicating a response exists. Subsequent callbacks check this flag and skip execution. The action method never runs.
Around Actions for Timing and Transactions
Around actions wrap the entire action execution. They receive a block representing the action and remaining callbacks. This pattern works well for timing, database transactions, or context setup.
# app/controllers/concerns/request_timing.rb
module RequestTiming
extend ActiveSupport::Concern
included do
around_action :log_request_timing
end
private
def log_request_timing
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
yield # Execute the action
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
Rails.logger.info(
"[Timing] #{controller_name}##{action_name} " \
"completed in #{(duration * 1000).round(2)}ms"
)
end
endThe yield statement is crucial—it executes the action. Code before yield runs first; code after runs when the action completes. Forgetting yield means the action never executes.
After Actions for Cleanup and Logging
After actions run once the action completes but before the response reaches the client. They work well for logging, analytics, or cleanup that shouldn't block the response.
# app/controllers/downloads_controller.rb
class DownloadsController < ApplicationController
before_action :set_document
after_action :track_download, only: :show
def show
send_file @document.file.path,
filename: @document.filename,
type: @document.content_type
end
private
def set_document
@document = Document.find(params[:id])
end
def track_download
# Runs after send_file but doesn't block it
DownloadTrackingJob.perform_later(
document_id: @document.id,
user_id: Current.user&.id,
ip_address: request.remote_ip,
user_agent: request.user_agent
)
end
endNote that after_action callbacks have access to the response but cannot modify it in ways that affect what the client receives—the response is already committed.
Callback Inheritance and Skipping
Callbacks defined in parent controllers apply to all child controllers. Override this behavior with skip_before_action, skip_after_action, or skip_around_action.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :authenticate_user!
before_action :set_current_request_details
private
def set_current_request_details
Current.request_id = request.request_id
Current.user_agent = request.user_agent
Current.ip_address = request.remote_ip
end
end
# app/controllers/public_pages_controller.rb
class PublicPagesController < ApplicationController
skip_before_action :authenticate_user!
def home
@featured_articles = Article.featured.limit(5)
end
def about
end
def pricing
end
end
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
skip_before_action :authenticate_user!, only: %i[new create]
def new
end
def create
# Login logic
end
def destroy
# Logout requires authentication
reset_session
redirect_to root_path
end
endThe only: and except: options work with skip methods too. This granular control allows precise callback management without duplicating logic.
Conditional Callbacks
Sometimes callbacks should run based on runtime conditions. Use if: and unless: options with a method name, proc, or lambda.
# app/controllers/api/base_controller.rb
module Api
class BaseController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :enforce_json_format
before_action :log_api_request, if: -> { Rails.env.production? }
after_action :track_api_usage, unless: :health_check?
private
def enforce_json_format
request.format = :json unless request.format.json?
end
def log_api_request
Rails.logger.info(
"[API] #{request.method} #{request.path} " \
"user=#{current_api_user&.id}"
)
end
def track_api_usage
ApiUsageTracker.record(
endpoint: "#{controller_name}##{action_name}",
user_id: current_api_user&.id,
response_status: response.status
)
end
def health_check?
controller_name == "health" && action_name == "show"
end
end
endCommon Callback Pitfalls
Several patterns cause problems with callbacks:
- Too many callbacks - Long callback chains obscure controller flow. Consider extracting logic into service objects when callbacks exceed 4-5.
- Order dependencies - Callbacks that depend on other callbacks' side effects create fragile code. Document dependencies clearly.
- Hidden redirects - Callbacks that redirect without obvious naming (like
ensure_subscription) confuse developers debugging request flow. - Expensive after_actions - Heavy processing in
after_actiondelays response delivery. Use background jobs instead.
Summary
Action callbacks eliminate duplication and centralize cross-cutting concerns. Use before_action for authentication, authorization, and resource loading. Reserve around_action for timing and transactions. Apply after_action for logging and lightweight tracking. Leverage skip_*_action methods to override inherited callbacks precisely. Keep callback chains short and their purposes obvious from naming.