Reading the Source · Card 7
How form_with builds an HTML form
You write form_with model: @post do |f| and Rails produces a complete <form> with the right URL, the right verb, a CSRF token, and inputs named like post[title]. Every piece of that has a reason.
1. Rails figures out the URL from the model.
When you pass model: @post, Rails uses your routes to decide where the form should POST. A new (unsaved) record goes to the collection URL (/posts). A persisted record goes to the member URL (/posts/42). The choice between create and update is driven by whether the model has been saved.
# Controller:
@post = Post.new # new, unsaved
@post = Post.find(params[:id]) # persisted
# View:
<%= form_with model: @post do |f| %>
...
<% end %>
# What Rails decides:
#
# @post.new_record? == true → action="/posts", method="POST" (create)
# @post.new_record? == false → action="/posts/42", method="POST" (update*)
#
# *The form ALWAYS uses method="POST". The actual verb (PATCH for update)
# is sent via a hidden field. See step 3. 📂 actionview/lib/action_view/helpers/form_helper.rb
2. The <form> tag gets standard attributes.
Rails opens a <form> element with the URL, the HTTP method, and a few attributes that matter (accept-charset, a default class based on whether the model is new or persisted, and the auto-generated id).
# For a new Post, the rendered output starts with:
<form action="/posts"
accept-charset="UTF-8"
method="post">
# For an existing Post (id: 42):
<form action="/posts/42"
accept-charset="UTF-8"
method="post">
# Note: method is always "post" in the HTML. HTML forms can only
# do GET or POST. The real verb (PATCH/PUT/DELETE) gets carried
# by a hidden field, which is the next thing Rails writes. 3. PATCH/PUT/DELETE travels in a hidden _method field.
HTML forms only support GET and POST as the method attribute. For PATCH, PUT, and DELETE, Rails uses a hidden input named _method that holds the intended verb. On the server, the Rack::MethodOverride middleware reads that field and pretends the request used the right verb before it reaches your routes.
# For a persisted Post (an update form), Rails inserts:
<input type="hidden" name="_method" value="patch" autocomplete="off">
# When the form submits, the browser sends a real POST. But by the
# time the request reaches your controller, Rack::MethodOverride
# has changed the request verb to PATCH. The router sees PATCH /posts/42,
# matches the resourceful update route, and dispatches to #update.
# This is also how button_to "Delete", post, method: :delete works. 📂 actionpack/lib/action_dispatch/middleware/method_override.rb
4. Every form gets a CSRF token.
Rails inserts another hidden input named authenticity_token with a token Rails generated for the session. When the form is submitted, the controller's protect_from_forgery mechanism checks that the token matches what's in the session. A missing or wrong token gets the request rejected with a InvalidAuthenticityToken error.
# Every form Rails emits has this:
<input type="hidden"
name="authenticity_token"
value="A7nF...long_random_string..."
autocomplete="off">
# The value is signed and tied to the user's session. An attacker
# who tries to submit a form from another site can guess the URL
# and the field names, but not this token. Without it, the request
# is rejected before reaching your action.
# This is why opting out of form_with and writing raw <form> tags
# usually breaks: you forget the token, every POST 422s. 📂 actionpack/lib/action_controller/metal/request_forgery_protection.rb
5. The block yields a FormBuilder that names inputs after the model.
The |f| in the block is a FormBuilder object. Every helper you call on it (f.text_field :title) generates an HTML input whose name attribute is namespaced under the model. That's why on the server params[:post][:title] works.
# View:
<%= form_with model: @post do |f| %>
<%= f.label :title %>
<%= f.text_field :title %>
<%= f.label :body %>
<%= f.text_area :body %>
<%= f.submit %>
<% end %>
# Rendered HTML (inside the <form>):
<label for="post_title">Title</label>
<input type="text" name="post[title]" id="post_title" value="...">
<label for="post_body">Body</label>
<textarea name="post[body]" id="post_body">...</textarea>
<input type="submit" name="commit" value="Update Post" data-disable-with="...">
# The "post[..]" naming is what makes Rack parse the body into:
# { "post" => { "title" => "...", "body" => "..." } }
# which is how params.require(:post).permit(:title, :body) works. 📂 actionview/lib/action_view/helpers/form_helper.rb, the FormBuilder class
6. The submitted form walks back through the same machinery.
When the user clicks Submit, the browser POSTs the form body to the action URL. Rack::MethodOverride sees the _method field and adjusts the verb. The router matches the route (the same Journey state machine from Card 2). The controller's protect_from_forgery checks the CSRF token. Then your action runs with params[:post] populated.
# Round trip for "edit a post":
#
# GET /posts/42/edit
# → renders the form with action="/posts/42", _method=patch, CSRF token
#
# User edits the title and clicks Update.
#
# POST /posts/42
# headers: Content-Type: application/x-www-form-urlencoded
# body: _method=patch
# &authenticity_token=A7nF...
# &post[title]=New+Title
# &post[body]=Hi
#
# Middleware stack:
# Rack::MethodOverride sees _method=patch → request verb becomes PATCH
# ActionDispatch::Cookies, Session, etc.
#
# Router:
# PATCH /posts/42 matches → PostsController#update, params[:id] = "42"
#
# Controller:
# protect_from_forgery checks authenticity_token (it matches)
# before_actions run
# update action runs with params.require(:post).permit(...) Why this is worth knowing
Once you see what form_with assembles, a lot of "weird form" problems get easy to debug:
- "My update form is sending a POST instead of a PATCH." The hidden
_methodfield is missing or the form isn't going throughform_with. The browser can only send POST; Rack does the rewrite based on_method. - "
InvalidAuthenticityTokenin production." Either the page was cached and served a stale token, or the form was hand-written without the token. The auto-generated CSRF input is what makes the controller's forgery check pass. - "My params are flat instead of nested under
post." The form was opened without a model, so the inputs are named bare (titleinstead ofpost[title]). Either passmodel:, or usescope: :post. - "My delete link doesn't actually delete." Same root cause as the update form: the
_methodfield has to be present and Rack::MethodOverride has to be in the middleware stack (it is by default; if you customized the stack, check).