โ† Back to Course

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_user is nil inside set_post." set_post is declared before authenticate_user! in the chain. Reorder, or move authenticate_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 render or redirect_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.