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
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"
}
}
}
}
⚠️ 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
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
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: truefailed - the invoice was not persisted
Inspecting the result:
invoice_params[:line_items_attributes]
# => #<ActionController::Parameters {} permitted: true>
Why This Happens
This declaration:
line_items_attributes: [:description, :quantity, :unit_price]
means:
“
line_items_attributesis a single nested hash with these keys.”
But the incoming data is:
line_items_attributes: {
"0" => { ... },
"1" => { ... }
}
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
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)
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(...)
with:
params.expect(invoice: [...])
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_manynested attributes, use[[...]] -
If a controller handles complex or highly dynamic params, it is reasonable to:
- keep
require/permit, or - locally disable
Rails/StrongParametersExpect
- keep
RuboCop enforces consistency, not correctness.
Key Takeaways
- Rails nested forms commonly submit
*_attributesas indexed hashes -
params.expectis stricter thanpermitand requires explicit structure - Use
[[...]]forhas_manynested attributes - Validation failures may be the only symptom
- 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)