action-controller
Rails Action Controller Expert
Write correct, secure, and idiomatic Rails controllers following Rails 8.1 conventions.
Philosophy
- Thin controllers — Business logic belongs in models/services, not controllers
- Strong parameters protect against mass assignment — Raw params let attackers set any attribute (admin flags, user IDs, etc.)
- Convention over configuration — Follow RESTful patterns; fight the urge to add custom actions
- Fail secure — Default to restricting access, then open up selectively
- One controller, one resource — If your controller handles two resources, split it
When To Use This Skill
- Writing new controller actions (CRUD or custom)
- Permitting parameters (especially nested hashes/arrays — this is where bugs live)
- Adding before_action filters for auth/authorization
- Setting up rescue_from for error handling
- Working with sessions, cookies, or flash messages
- Rendering responses or redirecting
- Implementing streaming or file downloads
- Configuring CSRF protection or HTTP auth
Instructions
Step 1: Check Existing Patterns
Look at the project's existing controllers first — consistency with the codebase matters more than textbook patterns:
# See what ApplicationController provides
cat app/controllers/application_controller.rb
# Find similar controllers
ls app/controllers/
# Check for shared concerns
ls app/controllers/concerns/
# Check routes for the resource
bin/rails routes | grep resource_name
Consistency beats "best practice."
Step 2: Controller Structure
Follow this ordering inside every controller:
class ArticlesController < ApplicationController
# 1. Includes/concerns
include Searchable
# 2. Constants (if any)
ITEMS_PER_PAGE = 25
# 3. Callbacks — order matters, they run top-to-bottom
before_action :authenticate_user!
before_action :set_article, only: [:show, :edit, :update, :destroy]
before_action :authorize_article, only: [:edit, :update, :destroy]
# 4. Public actions (RESTful order: index, show, new, create, edit, update, destroy)
def index
@articles = Article.all
end
def show; end
def new
@article = Article.new
end
def create
@article = Article.new(article_params)
if @article.save
redirect_to @article, notice: "Article created."
else
render :new, status: :unprocessable_entity
end
end
def edit; 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.", status: :see_other
end
# 5. Private methods
private
def set_article
@article = Article.find(params.expect(:id))
end
def authorize_article
redirect_to articles_path, alert: "Not authorized." unless @article.user == current_user
end
def article_params
params.expect(article: [:title, :body, :published])
end
end
Step 3: Strong Parameters
This is where most controller bugs come from — especially with nested hashes and arrays.
Use expect (Rails 8+) Over require + permit
# ✅ Rails 8+ — prefer expect (combines require + permit)
def article_params
params.expect(article: [:title, :body, :published])
end
# ⚠️ Older style — still works but expect is preferred
def article_params
params.require(:article).permit(:title, :body, :published)
end
Nested Hashes — The Danger Zone
# Nested hash (belongs_to address, has fields)
# Form sends: { user: { name: "Jo", address: { street: "123 Main", city: "NY" } } }
def user_params
params.expect(user: [:name, address: [:street, :city, :zip]])
end
# ⚠️ Wrong — this permits NOTHING inside address
def user_params
params.expect(user: [:name, :address]) # address is a hash, not a scalar!
end
Arrays of Scalars
# Array of simple values: { article: { title: "Hi", tag_ids: [1, 2, 3] } }
def article_params
params.expect(article: [:title, tag_ids: []])
end
Arrays of Hashes (accepts_nested_attributes_for)
# Array of nested objects — NOTE THE DOUBLE ARRAY SYNTAX [[...]]
# { project: { name: "X", tasks_attributes: [{ title: "A" }, { title: "B" }] } }
def project_params
params.expect(project: [:name, tasks_attributes: [[:title, :done, :id, :_destroy]]])
end
Double array [[...]] = "I expect an array of hashes, each with these keys."
This is the Rails 8 expect syntax. Agents get this wrong constantly.
Arbitrary Hash (Use Sparingly)
# When you genuinely can't enumerate keys (e.g., JSON metadata blob)
def product_params
params.expect(product: [:name, metadata: {}])
end
# ⚠️ metadata: {} permits ANY keys — only use when truly dynamic
Common Gotchas
| Bug | Fix |
|---|---|
permit(:tags) when tags is an array |
permit(tags: []) |
permit(:address) when address is a hash |
permit(address: [:street, :city]) |
permit(:images) for file uploads |
permit(images: []) for multiple files |
Nested attributes without _destroy and id |
permit(items_attributes: [[:name, :id, :_destroy]]) |
Using permit! to "just make it work" |
Enumerate your params — permit! allows attackers to set any attribute |
Step 4: Callbacks (before_action, after_action, around_action)
before_action
class PostsController < ApplicationController
before_action :authenticate_user!
before_action :set_post, only: [:show, :edit, :update, :destroy]
# Skip inherited callbacks selectively
skip_before_action :authenticate_user!, only: [:index, :show]
private
def set_post
@post = Post.find(params.expect(:id))
end
end
Key rules:
- Callbacks run in declaration order — put auth before resource loading
- A callback that renders or redirects halts the chain (remaining callbacks and the action won't run)
- Use
only:/except:to scope callbacks to specific actions skip_before_actiononly works for callbacks inherited from parent classes or registered earlier
around_action
around_action :wrap_in_transaction, only: [:create, :update]
private
def wrap_in_transaction
ActiveRecord::Base.transaction do
yield # executes the action
end
end
The yield is required — without it the action never executes.
Step 5: Rendering Responses
# Implicit render — renders app/views/posts/index.html.erb
def index
@posts = Post.all
# no explicit render needed
end
# Explicit render of a different template
render :new # same controller, different action template
render "posts/new" # cross-controller template
render plain: "OK" # plain text
render json: @post # JSON
render json: @post, status: :created # JSON with status
render html: "<h1>Hi</h1>".html_safe # raw HTML
render inline: "<%= 'hi' %>" # inline ERB (avoid)
render nothing: true, status: :ok # empty body (deprecated — use head)
# head — response with no body
head :no_content # 204
head :created, location: post_url(@post)
# Render with status (Turbo requires it for error responses)
render :new, status: :unprocessable_entity # 422 — required for Turbo
render :edit, status: :unprocessable_entity
For Turbo/Hotwire: Failed form submissions need status: :unprocessable_entity (422) — without it, Turbo ignores the response and the user sees no error feedback.
respond_to for Multiple Formats
def show
@post = Post.find(params.expect(:id))
respond_to do |format|
format.html # renders show.html.erb
format.json { render json: @post }
format.pdf { send_data generate_pdf(@post), filename: "post.pdf" }
end
end
Step 6: Redirects
redirect_to @post # redirect to show
redirect_to posts_path # redirect to index
redirect_to root_path, notice: "Done!" # with flash notice
redirect_to root_path, alert: "Oops!" # with flash alert
redirect_to root_path, status: :see_other # 303 — use for DELETE actions
# Redirect back (with fallback)
redirect_back fallback_location: root_path
# ⚠️ After DELETE, use status: :see_other (303)
# This prevents browsers from replaying the DELETE on redirect
def destroy
@post.destroy!
redirect_to posts_path, notice: "Deleted.", status: :see_other
end
Step 7: Flash Messages
# Set in redirect
redirect_to @post, notice: "Saved!"
redirect_to @post, alert: "Problem!"
# Set manually (available on NEXT request)
flash[:notice] = "Saved!"
flash[:alert] = "Problem!"
# Set for CURRENT request (use when rendering, not redirecting)
flash.now[:error] = "Could not save."
render :new, status: :unprocessable_entity
# Carry flash through an extra redirect
flash.keep
redirect_to another_path
Rule of thumb: flash[...] before redirect_to. flash.now[...] before render.
Step 8: Sessions and Cookies
Sessions
# Store
session[:current_user_id] = user.id
# Read
session[:current_user_id]
# Delete
session.delete(:current_user_id)
# Nuke everything (do this on login to prevent session fixation)
reset_session
Cookies
# Basic (deleted when browser closes)
cookies[:theme] = "dark"
# With expiration
cookies[:theme] = { value: "dark", expires: 1.year }
# Permanent (20 years)
cookies.permanent[:locale] = "en"
# Signed (tamper-proof, but readable)
cookies.signed[:user_id] = current_user.id
cookies.signed[:user_id] # => 42
# Encrypted (tamper-proof AND unreadable)
cookies.encrypted[:token] = "secret"
cookies.encrypted[:token] # => "secret"
# Delete
cookies.delete(:theme)
Step 9: Error Handling with rescue_from
class ApplicationController < ActionController::Base
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActiveRecord::RecordInvalid, with: :unprocessable
rescue_from ActionController::ParameterMissing, with: :bad_request
private
def not_found
respond_to do |format|
format.html { render file: Rails.root.join("public/404.html"), status: :not_found, layout: false }
format.json { render json: { error: "Not found" }, status: :not_found }
end
end
def unprocessable(exception)
respond_to do |format|
format.html { redirect_back fallback_location: root_path, alert: exception.message }
format.json { render json: { error: exception.message }, status: :unprocessable_entity }
end
end
def bad_request(exception)
render json: { error: exception.message }, status: :bad_request
end
end
Don't rescue Exception or StandardError — they catch things like SystemExit and SyntaxError, breaking Rails internals and hiding real bugs.
Step 10: CSRF Protection
Enabled by default. Key points:
# Default — raises exception on CSRF failure
protect_from_forgery with: :exception
# For API controllers — skip CSRF (use token auth instead)
class Api::BaseController < ActionController::API # no CSRF by default
end
Turbo and rails-ujs handle CSRF automatically. For custom fetch calls, read the token from the <meta name="csrf-token"> tag and send it as X-CSRF-Token header.
Step 11: HTTP Auth, Streaming, Request/Response
# Basic Auth — quick and dirty (admin panels, staging)
http_basic_authenticate_with name: "admin", password: ENV["ADMIN_PASSWORD"]
# Token Auth — for APIs
authenticate_or_request_with_http_token do |token, _options|
ActiveSupport::SecurityUtils.secure_compare(token, ENV["API_TOKEN"])
end
# Send generated data as download
send_data pdf_content, filename: "report.pdf", type: "application/pdf"
# Send existing file
send_file Rails.root.join("storage/report.pdf"), filename: "report.pdf"
# Live streaming — always close the stream
include ActionController::Live
response.headers["Content-Type"] = "text/event-stream"
response.stream.write "data: hello\n\n"
ensure
response.stream.close
# Useful request properties
request.remote_ip # client IP
request.get? / request.post? # method checks
request.headers["X-Custom"]
request.format # Mime::HTML, Mime::JSON, etc.
request.variant = :mobile # for device-specific views
Anti-Patterns to Avoid
- Fat controllers — Move business logic to models/services/form objects
permit!— Opens the door to mass assignment attacks; enumerate every permitted attributeparams[:foo]directly in model calls — Bypasses strong parameter filtering; go through the params method- Skipping CSRF broadly — Only skip for genuine API endpoints with token auth; CSRF protects against cross-site form submissions
rescue_from Exception— Catches syntax errors, SystemExit, and other things you don't want to swallow- Nested
if/elsechains in actions — Extract to service objects - Multiple renders/redirects — A controller action can only render or redirect once; use
and returnor early returns - Missing status on error renders — Turbo requires
status: :unprocessable_entity - Session for everything — Session is 4KB max with CookieStore; use the database for big data
- Redirect after DELETE without
:see_other— Can cause browsers to replay the DELETE
Quick Reference
See the references/ directory for detailed patterns and examples:
references/strong-params.md— Complete param permitting patterns (nested hashes, arrays, double array syntax)references/callbacks.md— All callback types, ordering rules, halting, skip patternsreferences/rendering.md— Full rendering options, redirects, flash messages, respond_to, variantsreferences/sessions-and-cookies.md— Session stores, cookie jar types, configurationreferences/security.md— rescue_from, CSRF, HTTP auth, CSP, log filtering, Force SSL, browser version controlreferences/streaming.md— send_data/send_file, SSE streaming, request/response objects, API controllers, health checks
More from thinkoodle/rails-skills
minitest
Expert guidance for writing fast, maintainable Minitest tests in Rails applications. Use when writing tests, converting from RSpec, debugging test failures, improving test performance, or following testing best practices. Covers model tests, policy tests, request tests, system tests, fixtures, and TDD workflows.
32stimulus
Expert guidance for building Stimulus controllers in Rails applications. Use when creating JavaScript behaviors, writing data-controller/data-action/data-target attributes, building interactive UI components, or working with Hotwire Stimulus. Covers controller creation, targets, values, actions, classes, outlets, lifecycle callbacks, progressive enhancement, and common patterns like clipboard, flash, modal, toggle, and form validation.
4testing
Expert guidance for Rails testing infrastructure, test types, and what to test. Use when writing tests, setting up a test suite, choosing between test types, configuring system tests (Capybara), request tests, integration tests, helper tests, mailer tests, job tests, Action Cable tests, parallel testing, CI setup, test database management, or improving test coverage. Covers the test runner, fixtures vs factories, parallel testing, system tests (drivers, screenshots), request tests, controller tests (legacy), helper tests, mailer tests, job tests, Action Cable tests, test coverage, CI patterns, and test database strategies. Trigger on "test", "testing", "test suite", "system test", "request test", "integration test", "test runner", "parallel testing", "capybara", "test database", "CI testing", "test coverage".
4i18n
Expert guidance for Rails I18n (internationalization and localization). Use when working with translations, locale files, t() / l() helpers, lazy lookups, pluralization, interpolation, date/time/number formatting, model translations, error message translations, setting locale from URL/header/session, or organizing YAML translation files. Triggers on "i18n", "internationalization", "translation", "locale", "localize", "t()", "translate", "multilingual", "pluralization", "locale file", "YAML translation".
4form-helpers
Expert guidance for building forms in Rails 8 applications. Use when creating forms, form_with, form helpers, nested forms, select helpers, file uploads, form builders, accepts_nested_attributes_for, fields_for, collection_select, grouped_collection_select, date/time selects, checkboxes, radio buttons, rich text areas, or any form-related view code. Covers model-backed forms, URL-based forms, complex nested attributes with _destroy, custom form builders, CSRF tokens, strong parameters for nested forms, and Stimulus integration.
4turbo
Expert guidance for building modern Rails UIs with Turbo (Drive, Frames, Streams). Use when implementing partial page updates, real-time broadcasts, turbo frames, turbo streams, hotwire patterns, turbo_frame_tag, turbo_stream responses, lazy loading frames, morphing, page refreshes, or any "turbo" related Rails feature. Covers Turbo Drive navigation, Turbo Frames for scoped updates, Turbo Streams for real-time HTML delivery, and Turbo 8 morphing.
4