โ† Back to Course

Reading the Source ยท Card 5

How params actually works (and what permit does)

You read params[:id] in every controller. You write params.require(:post).permit(:title, :body) for every form. The first looks like a hash. The second feels like magic. Both are doing something specific that's worth seeing.

1. params is not a Hash.

When you read params[:title], you're reading from an object of type ActionController::Parameters. It looks like a hash and supports most of the same methods ([], dig, to_h, each), but it has one extra rule on top: ActiveModel will refuse to mass-assign attributes from it unless you've explicitly marked which keys are safe.

params.class
# => ActionController::Parameters

# Reading works like a Hash:
params[:id]               # => "42"
params.dig(:user, :name)  # => "Karim"

# But this raises ActiveModel::ForbiddenAttributesError:
Post.create(params[:post])

# Why? Because params is "unpermitted" by default. Until you've
# explicitly named which keys are safe, Rails won't let you assign
# them in bulk to a model.

๐Ÿ“‚ actionpack/lib/action_controller/metal/strong_parameters.rb

2. Rails fills params from three places.

The same params object holds keys from the URL query string, the request body (form fields or JSON), and the route captures (the :id in /posts/:id). Rails merges them all into one object before your action runs. If the same key appears in two sources, the rules of merging decide which wins, but in practice you rarely see this collision.

# Given this request:
#
#   PATCH /posts/42?utm_source=email
#   Body (form): post[title]=New+Title&post[body]=Hi
#
# params ends up as (roughly):
#
#   {
#     "controller" => "posts",
#     "action"     => "update",
#     "id"         => "42",              # from /posts/:id  (route capture)
#     "utm_source" => "email",           # from ?utm_source (query string)
#     "post" => {                        # from the form body
#       "title" => "New Title",
#       "body"  => "Hi"
#     }
#   }

๐Ÿ“‚ actionpack/lib/action_dispatch/http/parameters.rb

3. permit returns a new object.

permit(:title, :body) doesn't mark the original params as safe. It returns a brand new Parameters object that contains only the keys you listed, with an internal flag set to permitted = true. ActiveModel checks that flag when it does mass-assignment.

# params[:post] contains: title, body, admin_flag
safe = params[:post].permit(:title, :body)

safe                       # => <Parameters {title: "...", body: "..."} permitted: true>
safe[:admin_flag]          # => nil (filtered out)
safe.permitted?            # => true

# The original is unchanged:
params[:post][:admin_flag] # => "yes"  (still there in the original)
params[:post].permitted?   # => false

# Now this works:
Post.create(safe)

๐Ÿ“‚ actionpack/lib/action_controller/metal/strong_parameters.rb, the permit method

4. require just narrows the focus.

params.require(:post) returns the inner Parameters object for the :post key, and raises an error if that key is missing or empty. It's a one-liner that says "the form should have submitted a post hash, and if it didn't, fail fast."

# Equivalent to:
#
#   if params[:post].blank?
#     raise ActionController::ParameterMissing, :post
#   end
#   params[:post]

# Chained, the common pattern is:
params.require(:post).permit(:title, :body)

# Step by step:
#   1. require(:post) โ€” pull out params[:post] or raise
#   2. permit(:title, :body) โ€” keep only these two keys

# The result is a permitted Parameters with just title and body,
# safe to pass to Post.create or Post#update.

๐Ÿ“‚ same file, the require method

5. For nested data, you describe the shape.

permit only lets through scalar values (strings, numbers, booleans) by default. If your form sends an array, a hash, or nested attributes, you have to spell out the structure so Rails knows it's intentional.

# A flat list of strings:
params.permit(tags: [])
# allows params[:tags] = ["a", "b", "c"]

# A hash of known keys:
params.permit(address: [:street, :city, :zip])
# allows params[:address] = { street: "...", city: "...", zip: "..." }

# Nested attributes (has_many with accepts_nested_attributes_for):
params.require(:post).permit(
  :title,
  :body,
  comments_attributes: [:id, :body, :_destroy]
)

# Anything not described is silently filtered out. This is the
# "permit only what you describe" contract.

๐Ÿ“‚ same file, the nested-attributes handling inside permit

6. The Rails idiom: a private *_params method.

The convention is to put the permit logic in a private method named after the resource. The action calls it. This keeps the actions clean and gives you one place to update if the permitted shape changes.

class PostsController < ApplicationController
  def create
    @post = Post.new(post_params)
    if @post.save
      redirect_to @post
    else
      render :new, status: :unprocessable_entity
    end
  end

  def update
    if @post.update(post_params)
      redirect_to @post
    else
      render :edit, status: :unprocessable_entity
    end
  end

  private

  def post_params
    params.require(:post).permit(:title, :body, tag_ids: [])
  end
end

# If you add a new field, you change one method, not every action.

Why this is worth knowing

Once params + permit makes sense, a few common bugs become obvious:

  • "My checkbox value is missing on update." A checkbox isn't in the permit list. The fix is one line, in post_params.
  • "The form submitted but the model didn't update." A new field was added to the form, but post_params was forgotten. The value is in params[:post], but it gets filtered out by permit.
  • "I'm getting ActionController::ParameterMissing." The form didn't submit the expected wrapper key. Usually because form_with wasn't given a model and the field names came out un-namespaced.
  • "Strong params blocked a nested array." The nested shape needs to be described in permit. Arrays need key: [], hashes need key: [:a, :b].