Ruby on Rails Zero to Advanced #8: Forms, Strong Parameters & Full CRUD
Until now we created records in the console. Users need to do it in the browser. In this part we build a complete CRUD interface for articles by hand: form_with, strong parameters, error messages, flash notices and delete buttons. Then we compare it with what rails generate scaffold produces in a few seconds, so you know exactly what the generator does for you.
The seven RESTful actions
resources :articles (part 5) maps the URLs. Each action has a clear job:
| Action | HTTP | Purpose |
|---|---|---|
index | GET /articles | List records |
show | GET /articles/:id | Show one record |
new | GET /articles/new | Display an empty form |
create | POST /articles | Save the submitted form |
edit | GET /articles/:id/edit | Display a filled form |
update | PATCH /articles/:id | Save the changes |
destroy | DELETE /articles/:id | Delete the record |
The controller
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
before_action :set_article, only: [:show, :edit, :update, :destroy]
def index
@articles = Article.order(created_at: :desc)
end
def show
end
def new
@article = Article.new
end
def create
@article = Article.new(article_params)
@article.user = User.first # temporary: part 9 replaces this with the logged-in user
if @article.save
redirect_to @article, notice: "Article was created."
else
render :new, status: :unprocessable_entity
end
end
def edit
end
def update
if @article.update(article_params)
redirect_to @article, notice: "Article was updated."
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@article.destroy
redirect_to articles_path, notice: "Article was deleted.", status: :see_other
end
private
def set_article
@article = Article.find(params[:id])
end
def article_params
params.expect(article: [:title, :body, :published])
end
end
Key patterns: before_action removes the repeated find; on success we redirect (so refreshing the page does not resubmit the form); on failure we render the form again with status 422 so the user sees the errors and their typed values. status: :see_other (303) after delete makes Turbo follow the redirect with a GET.
Strong parameters
Never pass raw params to new or update. A malicious user could add fields you did not expect (admin=1, user_id=5) and change them. This attack is called mass assignment. Strong parameters whitelist exactly which keys are allowed:
params = ActionController::Parameters.new(
article: { title: "Hello", body: "Text", user_id: 99 }
)
Article.new(params[:article])
params.expect(article: [:title, :body])
params.require(:article).permit(:title, :body)
Output:
(blog):5:in '<main>': ActiveModel::ForbiddenAttributesError (ActiveModel::ForbiddenAttributesError)
=> #<ActionController::Parameters {"title" => "Hello", "body" => "Text"} permitted: true>
=> #<ActionController::Parameters {"title" => "Hello", "body" => "Text"} permitted: true>
params.expect is new in Rails 8 and also returns a 400 Bad Request when the shape of the params is wrong. On Rails 7, use the equivalent require(...).permit(...). In development, rejected keys are logged:
Output (server log when someone posts an extra user_id field):
Started POST "/articles" for 127.0.0.1 at 2026-10-13 14:02:10 +0700
Processing by ArticlesController#create as TURBO_STREAM
Parameters: {"authenticity_token" => "[FILTERED]", "article" => {"title" => "Hi", "body" => "", "published" => "0", "user_id" => "99"}, "commit" => "Create Article"}
Unpermitted parameter: :user_id. Context: { controller: ArticlesController, action: create, ... }
Completed 422 Unprocessable Content in 18ms
The title "Hi" failed the length validation from part 7, so the response is 422 and the form is shown again.
Building the form with form_with
Put the form in a partial so new and edit share it:
<!-- app/views/articles/_form.html.erb -->
<%= form_with model: article do |form| %>
<% if article.errors.any? %>
<div>
<h2><%= pluralize(article.errors.count, "error") %> prevented this article from being saved:</h2>
<ul>
<% article.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div>
<%= form.label :title %>
<%= form.text_field :title %>
</div>
<div>
<%= form.label :body %>
<%= form.text_area :body, rows: 6 %>
</div>
<div>
<%= form.check_box :published %>
<%= form.label :published %>
</div>
<%= form.submit %>
<% end %>
<!-- app/views/articles/new.html.erb -->
<h1>New article</h1>
<%= render "form", article: @article %>
<!-- app/views/articles/edit.html.erb -->
<h1>Edit article</h1>
<%= render "form", article: @article %>
form_with model: looks at the object. For a new record it posts to /articles; for a saved one it sends a PATCH to /articles/:id. The rendered HTML of /articles/new:
Output:
<form action="/articles" accept-charset="UTF-8" method="post"><input type="hidden" name="authenticity_token" value="kX3v...random..." autocomplete="off" />
<div>
<label for="article_title">Title</label>
<input type="text" name="article[title]" id="article_title" />
</div>
<div>
<label for="article_body">Body</label>
<textarea rows="6" name="article[body]" id="article_body">
</textarea>
</div>
<div>
<input name="article[published]" type="hidden" value="0" autocomplete="off" /><input type="checkbox" value="1" name="article[published]" id="article_published" />
<label for="article_published">Published</label>
</div>
<input type="submit" name="commit" value="Create Article" data-disable-with="Create Article" />
</form>
Notice name="article[title]": that is why the data arrives nested under params[:article]. The hidden authenticity_token protects against CSRF attacks, and the hidden value="0" makes an unchecked checkbox still send false. On the edit page the button reads "Update Article" and a hidden _method=patch field is added.
Index, show, flash messages and delete
<!-- app/views/layouts/application.html.erb (inside body) -->
<% if notice %>
<p class="notice"><%= notice %></p>
<% end %>
<%= yield %>
<!-- app/views/articles/show.html.erb -->
<h1><%= @article.title %></h1>
<p><%= @article.body %></p>
<%= link_to "Edit", edit_article_path(@article) %> |
<%= link_to "Back", articles_path %>
<%= button_to "Delete", @article, method: :delete,
form: { data: { turbo_confirm: "Are you sure?" } } %>
After creating "Learning Rails forms", the show page renders:
Output:
<p class="notice">Article was created.</p>
<h1>Learning Rails forms</h1>
<p>form_with is great.</p>
<a href="/articles/4/edit">Edit</a> |
<a href="/articles">Back</a>
<form data-turbo-confirm="Are you sure?" class="button_to" method="post" action="/articles/4"><input type="hidden" name="_method" value="delete" autocomplete="off" /><button type="submit">Delete</button><input type="hidden" name="authenticity_token" value="Qm9...random..." autocomplete="off" /></form>
Use button_to (a real form) for delete, not link_to: links should never change data. The flash notice survives exactly one redirect and then disappears.
Scaffold vs manual
Everything above can be generated in one command:
bin/rails generate scaffold Product name:string price:integer
Output (shortened):
invoke active_record
create db/migrate/20261013070000_create_products.rb
create app/models/product.rb
invoke resource_route
route resources :products
invoke scaffold_controller
create app/controllers/products_controller.rb
invoke erb
create app/views/products/index.html.erb
create app/views/products/edit.html.erb
create app/views/products/show.html.erb
create app/views/products/new.html.erb
create app/views/products/_form.html.erb
create app/views/products/_product.html.erb
invoke jbuilder
create app/views/products/index.json.jbuilder
create app/views/products/show.json.jbuilder
create app/views/products/_product.json.jbuilder
invoke test_unit
create test/controllers/products_controller_test.rb
create test/system/products_test.rb
| Scaffold | Manual | |
|---|---|---|
| Speed | Seconds | Minutes to hours |
| Extra files | JSON views, system tests, everything | Only what you need |
| Understanding | Easy to use without understanding | You know every line |
| Best for | Admin screens, prototypes, learning by reading | Customer-facing features |
A good habit: build CRUD manually a few times (as we did), then use scaffold and read what it generates. You will recognise every pattern, and you can delete what you do not need.
Common mistakes
- Rendering without a status. With Turbo (default in Rails 7+), a failed form must return
status: :unprocessable_entityor the errors will not appear. - Redirecting on failure.
redirect_to new_article_pathafter a failed save loses the errors and the user's input. Render instead. - Permitting too much. Never permit
user_id,roleoradminfrom a public form. Set them in the controller from trusted data. - Using link_to for delete. Crawlers and prefetching can trigger GET links. Use
button_to. - Forgetting the partial local. Inside
_form.html.erbusearticle, passed explicitly, not@article; it keeps partials reusable.
Exercise
Let readers comment on an article. Nest the route and build the form with an array model:
# config/routes.rb
resources :articles do
resources :comments, only: [:create, :destroy]
end
<!-- app/views/articles/show.html.erb -->
<%= form_with model: [@article, Comment.new] do |form| %>
<%= form.text_field :author_name %>
<%= form.text_area :body %>
<%= form.submit "Post comment" %>
<% end %>
Write CommentsController#create that finds the article, builds @article.comments.new(comment_params), saves it and redirects back. The opening form tag should render as:
Output:
<form action="/articles/4/comments" accept-charset="UTF-8" method="post">
Summary
You built full CRUD by hand: a controller with seven actions and before_action, strong parameters to block mass assignment, a shared form_with partial with error messages, flash notices and a safe delete button. You also saw what scaffold generates and when to use it. In part 9 we add authentication with has_secure_password, sessions and before_action guards, and write our first tests.
Related articles
PostgreSQL Zero to Advanced #8: Indexes & Performance
Read query plans with EXPLAIN ANALYZE and speed up a 500,000-row table with B-tree, composite, partial and GIN indexes.
PHP Zero to Advanced #8: Composer, Namespaces, PSR-4 Autoloading & Exceptions
Structure real PHP projects: organize code with namespaces, manage dependencies with Composer, autoload classes with PSR-4, and handle errors cleanly with exceptions.
Python Zero to Advanced #8: OOP - Classes, Inheritance, Dunder Methods & Dataclasses
Model real-world things with classes: attributes, methods, class vs instance data, properties, inheritance and super(), dunder methods like __repr__ and __eq__, and modern dataclasses.