Rails 8 Strong Parameters: The Double-Bracket Fix for Nested Attributes
Source: Dev.to
Problem
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.
A sharp edge appears with nested attributes submitted as indexed hashes: they can be silently filtered out, causing validations to fail in non‑obvious ways.
Model
class 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_attributesis not an array; it is a hash keyed by dynamic numeric strings ("0","1", …). This distinction is the root cause of the bug.
Using require/permit (Works)
def invoice_params
params.require(:invoice).permit(
:date,
:due_date,
:client_id,
line_items_attributes: [:description, :quantity, :unit_price, :_destroy]
)
end
With permit, the nested attributes are assigned correctly and the validation passes.
Using expect (Failure)
def invoice_params
params.expect(
invoice: [
:date,
:due_date,
:client_id,
line_items_attributes: [:description, :quantity, :unit_price, :_destroy]
]
)
end
- No strong‑parameter error is raised.
line_items_attributesis filtered out, so no line items are assigned.validates :line_items, presence: truefails and the invoice is not persisted.
Inspecting the result:
invoice_params[:line_items_attributes]
# => #
The declaration line_items_attributes: [:description, :quantity, :unit_price] tells Rails that a single nested hash with those keys is expected. The incoming data, however, is an indexed hash collection.
The Fix: Double Brackets for Collections
To express “a collection of nested hashes” you must wrap the inner array in another array:
def invoice_params
params.expect(
invoice: [
:date,
:due_date,
:client_id,
line_items_attributes: [[
:id,
:description,
:quantity,
:unit_price,
:_destroy
]]
]
)
end
- 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 (
[{...}, {...}])
Test Before and After
# Before fix – no line items created
expect {
post invoices_path, params: valid_params
}.to not_change(Invoice, :count)
.and change(LineItem, :count).by(0)
# After fix – line items created
expect {
post invoices_path, params: valid_params
}.to change(Invoice, :count).by(1)
.and change(LineItem, :count).by(2)
Shape vs. Declaration Summary
| 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 of the incoming parameters, not just the values.
Guidance for Rails 8 Migration
- RuboCop often suggests replacing
params.require(...).permit(...)withparams.expect(...). - The Rails/StrongParametersExpect cop does not automatically handle the indexed‑hash format used by Rails nested forms (
*_attributes). A naïve rewrite can change behavior. - For
has_manynested attributes, always use the double‑bracket syntax ([[...]]). - When dealing with highly dynamic or complex params, it may be reasonable to keep
require/permitor to disable the cop locally.
Takeaways
params.expectis stricter thanpermitand requires an explicit structure.- Use
[[...]]for collections of nested hashes. - Validation failures may be the only symptom of a silently dropped nested attribute.
- Request specs are essential when changing strong‑parameter handling.
If this saved you time—or cost you some before you found it—you’re not alone.