Reading the Source ยท Card 6
How before_action chains run (and where halting happens)
You write before_action :authenticate_user! at the top of a controller and somehow every action runs that method first. Then your controller inherits from another one that has more callbacks, and the order matters. Here's what's actually happening.
1. before_action is a method call at boot.
Like belongs_to or get, before_action is a regular method that runs when the class is loaded. It takes the method name you passed and adds it to a list called the callback chain, stored on the controller class. No callback fires at this point. Rails just remembers the registration.
class PostsController < ApplicationController
before_action :authenticate_user!
before_action :set_post, only: [:show, :edit, :update, :destroy]
def show; end
# ...
end
# When this file loads, Rails has done roughly:
#
# PostsController._process_action_callbacks
# << Callback.new(:authenticate_user!, kind: :before, options: {})
# PostsController._process_action_callbacks
# << Callback.new(:set_post, kind: :before,
# options: { only: [:show, :edit, :update, :destroy] })
#
# Two entries on the chain. Still no execution. ๐ actionpack/lib/abstract_controller/callbacks.rb
2. The chain is shared with parent classes.
Your ApplicationController usually has its own before_actions. So does Devise's mixin if you use it. When your controller inherits from them, the chain gets concatenated. Parent callbacks come first, then child callbacks, in declaration order.
class ApplicationController < ActionController::Base
before_action :set_locale
before_action :authenticate_user!
end
class PostsController < ApplicationController
before_action :set_post, only: [:show, :edit]
end
# When a request hits PostsController#show, the chain that runs is:
#
# 1. :set_locale (from ApplicationController)
# 2. :authenticate_user! (from ApplicationController)
# 3. :set_post (from PostsController, this action is in :only)
#
# Order matters. set_post can rely on current_user being available
# because authenticate_user! ran first. Reverse the order and you
# can get nil pointer errors. 3. The chain runs around your action, not just before.
Rails actually has three kinds of callbacks: before_action, around_action, and after_action. Together they wrap your action method. Around-actions get a block they have to yield to, which runs the inner part of the chain (more befores, the action, the afters).
# Conceptually, what Rails does on each request:
#
# run_before_actions
# for each around_action:
# around_action.call do
# run_before_actions
# call_the_action
# run_after_actions
# end
# end
# run_after_actions
# A typical around-action looks like:
class PostsController < ApplicationController
around_action :with_locale
private
def with_locale
I18n.with_locale(params[:locale] || I18n.default_locale) do
yield # <-- the inner chain (including your action) runs here
end
end
end 4. Rendering or redirecting halts the chain.
If a before_action calls render or redirect_to, Rails skips the rest of the before-callbacks AND your action. The after-callbacks still run. This is how authentication and authorization stop a request cleanly.
class PostsController < ApplicationController
before_action :authenticate_user!
before_action :require_admin
def index; @posts = Post.all; end
private
def authenticate_user!
redirect_to login_path unless current_user
end
def require_admin
render :forbidden, status: :forbidden unless current_user.admin?
end
end
# Anonymous user hits GET /posts:
# 1. authenticate_user! runs โ redirect_to login_path
# 2. Rails sees a response has been set โ halts the chain
# 3. require_admin does NOT run
# 4. index does NOT run
# 5. after-callbacks still run (logging, etc.) ๐ actionpack/lib/abstract_controller/callbacks.rb, the response check
5. only: and except: filter which actions get the callback.
The options you pass to before_action are stored on the Callback object. At request time, Rails checks the current action name against the only: / except: lists and skips the callback if it doesn't apply.
class PostsController < ApplicationController
before_action :set_post, only: [:show, :edit, :update, :destroy]
end
# At request time:
#
# action = :index โ set_post is SKIPPED (not in :only list)
# action = :show โ set_post RUNS
# action = :edit โ set_post RUNS
# action = :new โ set_post is SKIPPED
#
# You can also use a Proc for dynamic filtering:
#
# before_action :set_post, if: -> { params[:id].present? }
#
# The Proc runs at request time, evaluated against the controller instance. 6. Skipping inherited callbacks in a subclass.
When a subclass needs to opt out of a parent's callback, you use skip_before_action. It removes the entry from this controller's copy of the chain, without touching the parent.
class ApplicationController < ActionController::Base
before_action :authenticate_user!
end
class PublicController < ApplicationController
skip_before_action :authenticate_user!, only: [:landing, :pricing]
end
# A request to PublicController#landing runs:
# (no authenticate_user! โ it was skipped)
# landing
#
# A request to PublicController#dashboard (an action not in :only) runs:
# authenticate_user!
# dashboard
#
# skip_before_action is the escape hatch. Use it sparingly. Each
# `skip` is a place a future reader has to remember exists. Why this is worth knowing
Once you see the chain, a lot of "weird controller behavior" becomes readable:
- "
current_useris nil insideset_post."set_postis declared beforeauthenticate_user!in the chain. Reorder, or moveauthenticate_user!to the parent class. - "My before_action keeps running on actions where it shouldn't." The
only:/except:list got out of sync with the actions. Or the callback was declared on a parent class without scoping. - "My action runs even though the user isn't logged in." A before_action returned without calling
renderorredirect_to. The chain only halts if a response has been set. - "My after_action ran even after a redirect." That's by design. Only befores and the action itself get skipped on halt. Afters and arounds (around the response) still run.