DEV Community

Olumuyiwa Osiname
Olumuyiwa Osiname

Posted on

Rails 8 Strong Parameters: The Double-Bracket Fix for Nested Attributes

When upgrading to Rails 8, you may start using params.expect—often prompted by RuboCop’s Rails/StrongParametersExpect—to make strong parameter contracts more explicit.

There is a sharp edge: nested attributes submitted as indexed hashes can be silently filtered out, causing validations to fail in non-obvious ways.

This post documents a real failure mode we hit while building SoloBooks’ invoicing flow—and the fix that makes expect work reliably with Rails nested attributes.

All parameter examples below are simplified and anonymized to illustrate structure only.


The Problem: Invoices Failed Validation After Switching to expect

Invoices in SoloBooks must have at least one line item. After switching from require(...).permit(...) to params.expect(...), invoice creation began failing validation.

At first glance nothing looked wrong:

  • the request succeeded
  • parameters appeared present
  • no strong-parameter error was raised

But the invoice was rejected because no line items were actually assigned.

A request spec caught it immediately.


The Setup

Our Invoice model accepts nested attributes and validates presence of line items:

class Invoice < ApplicationRecord
  has_many :line_items, dependent: :destroy

  accepts_nested_attributes_for :line_items,
                                allow_destroy: true,
                                reject_if: :all_blank

  validates :line_items, presence: true
end
Enter fullscreen mode Exit fullscreen mode

The Incoming Params (Critical Detail)

This is the actual shape the controller receives from a Rails nested form / stimulus-rails-nested-form setup:

{
  "invoice" => {
    "date" => "2026-01-11",
    "due_date" => "2026-01-12",
    "client_id" => "123",
    "line_items_attributes" => {
      "0" => {
        "description" => "Web Development",
        "quantity" => "10",
        "unit_price" => "150"
      },
      "1" => {
        "description" => "UI Design",
        "quantity" => "5",
        "unit_price" => "200"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

⚠️ Important:
line_items_attributes is not an array. It is a hash keyed by dynamic numeric strings ("0", "1", …).

This distinction is the root cause of the bug.


Step 1: Working Version with require/permit

def invoice_params
  params.require(:invoice).permit(
    :date,
    :due_date,
    :client_id,
    line_items_attributes: [:description, :quantity, :unit_price, :_destroy]
  )
end
Enter fullscreen mode Exit fullscreen mode

With permit, nested attributes were assigned correctly and validation passed.


Step 2: Switching to expect (Unexpected Validation Failure)

def invoice_params
  params.expect(
    invoice: [
      :date,
      :due_date,
      :client_id,
      line_items_attributes: [:description, :quantity, :unit_price, :_destroy]
    ]
  )
end
Enter fullscreen mode Exit fullscreen mode

No error was raised by strong parameters, but line_items_attributes was filtered out.

As a result:

  • no line items were assigned
  • validates :line_items, presence: true failed
  • the invoice was not persisted

Inspecting the result:

invoice_params[:line_items_attributes]
# => #<ActionController::Parameters {} permitted: true>
Enter fullscreen mode Exit fullscreen mode

Why This Happens

This declaration:

line_items_attributes: [:description, :quantity, :unit_price]
Enter fullscreen mode Exit fullscreen mode

means:

line_items_attributes is a single nested hash with these keys.”

But the incoming data is:

line_items_attributes: {
  "0" => { ... },
  "1" => { ... }
}
Enter fullscreen mode Exit fullscreen mode

That is a collection represented as an indexed hash, not a single hash.

params.expect enforces structure strictly. When the shape does not match, nested attributes are silently dropped.


The Fix: Double Brackets

To express “a collection of nested hashes,” you must use double brackets:

def invoice_params
  params.expect(
    invoice: [
      :date,
      :due_date,
      :client_id,
      line_items_attributes: [[
        :id,
        :description,
        :quantity,
        :unit_price,
        :_destroy
      ]]
    ]
  )
end
Enter fullscreen mode Exit fullscreen mode

What This Means

  • Inner array → permitted keys for each line item
  • Outer array → indicates a repeated / collection-like structure

This matches both:

  • indexed hashes ("0" => {...})
  • arrays of hashes ([{...}, {...}])

Verified by Request Spec

expect {
  post invoices_path, params: valid_params
}.to not_change(Invoice, :count)
   .and change(LineItem, :count).by(0)

# After fix:
expect {
  post invoices_path, params: valid_params
}.to change(Invoice, :count).by(1)
 .and change(LineItem, :count).by(2)
Enter fullscreen mode Exit fullscreen mode

Mental Model for expect

Incoming shape expect declaration
Single nested hash line_items_attributes: [:description]
Indexed hash ("0" => {...}) line_items_attributes: [[:description]]
Array of hashes line_items_attributes: [[:description]]

If nested records disappear or validations fail unexpectedly, inspect the shape, not the values.


A Note on RuboCop and Rails/StrongParametersExpect

RuboCop often triggers this issue.

The Rails/StrongParametersExpect cop encourages replacing:

params.require(:invoice).permit(...)
Enter fullscreen mode Exit fullscreen mode

with:

params.expect(invoice: [...])
Enter fullscreen mode Exit fullscreen mode

The intent is good: expect makes parameter structure explicit.

However, the cop does not account for how Rails nested forms commonly submit *_attributes—as indexed hashes with dynamic keys. A naïve rewrite is not behavior-preserving.

Practical Guidance

  • Always verify nested attribute shapes when adopting expect
  • For has_many nested attributes, use [[...]]
  • If a controller handles complex or highly dynamic params, it is reasonable to:

    • keep require/permit, or
    • locally disable Rails/StrongParametersExpect

RuboCop enforces consistency, not correctness.


Key Takeaways

  1. Rails nested forms commonly submit *_attributes as indexed hashes
  2. params.expect is stricter than permit and requires explicit structure
  3. Use [[...]] for has_many nested attributes
  4. Validation failures may be the only symptom
  5. Request specs are essential when changing strong parameter handling

Final Thought

This bug is subtle, silent at the strong-parameter layer, and easy to ship if you rely only on manual testing. If you are migrating to params.expect in Rails 8, nested attributes deserve special attention.

If this saved you time—or cost you some before you found it—you’re not alone.


Top comments (0)