Forms are where users hand your app messy, incomplete, or malicious input. Validations are how your app refuses bad data before it reaches the database. If you’re building AI features, this matters even more. Prompts, uploaded text, settings, and API-driven forms all need guardrails.
In this post, we’ll build a simple document form in Rails and validate it properly.
Generate a resource
If you’ve been following along, you already have a Document model. Let’s assume it looks like this:
class Document < ApplicationRecord
belongs_to :project
end
And the table has these columns:
project_idtitlebodystatus
Now create a controller if you don’t already have one:
bin/rails generate controller Documents new create edit update
Add routes in config/routes.rb:
Rails.application.routes.draw do
resources :projects do
resources :documents, only: [:new, :create, :edit, :update]
end
end
Nested routes make sense here because a document belongs to a project.
Add model validations
Start with the model. Put the rules close to the data.
app/models/document.rb
class Document < ApplicationRecord
belongs_to :project
validates :title, presence: true, length: { maximum: 120 }
validates :body, presence: true, length: { minimum: 20 }
validates :status, presence: true, inclusion: { in: %w[draft published archived] }
end
This gives you three useful protections:
- no blank titles
- no tiny document bodies
- no random status values like
doneorweird
Test it in the console:
bin/rails console
doc = Document.new(title: "", body: "short", status: "wat")
doc.valid?
# => false
doc.errors.full_messages
# => [
# "Title can't be blank",
# "Body is too short (minimum is 20 characters)",
# "Status is not included in the list"
# ]
That’s the contract your form will rely on.
Build the controller with strong parameters
Strong parameters are Rails’ way of saying: only accept the fields I explicitly allow.
app/controllers/documents_controller.rb
class DocumentsController < ApplicationController
before_action :set_project
before_action :set_document, only: [:edit, :update]
def new
@document = @project.documents.new(status: "draft")
end
def create
@document = @project.documents.new(document_params)
if @document.save
redirect_to @project, notice: "Document created."
else
render :new, status: :unprocessable_entity
end
end
def edit
end
def update
if @document.update(document_params)
redirect_to @project, notice: "Document updated."
else
render :edit, status: :unprocessable_entity
end
end
private
def set_project
@project = Project.find(params[:project_id])
end
def set_document
@document = @project.documents.find(params[:id])
end
def document_params
params.require(:document).permit(:title, :body, :status)
end
end
The line that matters most:
params.require(:document).permit(:title, :body, :status)
If the user submits extra fields, Rails ignores them. That prevents mass-assignment bugs like someone trying to set user_id, admin, or other attributes you never meant to expose.
Build the form
Create a partial so both new and edit can share it.
app/views/documents/_form.html.erb
<%= form_with model: [@project, @document] do |form| %>
<% if @document.errors.any? %>
<div style="color: red; margin-bottom: 1rem;">
<h3><%= pluralize(@document.errors.count, "error") %> prevented this document from saving:</h3>
<ul>
<% @document.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div>
<%= form.label :title %><br>
<%= form.text_field :title %>
</div>
<div>
<%= form.label :body %><br>
<%= form.text_area :body, rows: 10 %>
</div>
<div>
<%= form.label :status %><br>
<%= form.select :status, [["Draft", "draft"], ["Published", "published"], ["Archived", "archived"]] %>
</div>
<div style="margin-top: 1rem;">
<%= form.submit %>
</div>
<% end %>
Then render it from new.html.erb:
<h1>New Document</h1>
<%= render "form" %>
And from edit.html.erb:
<h1>Edit Document</h1>
<%= render "form" %>
Now when validation fails, Rails re-renders the form, keeps the submitted values, and shows the error messages.
Why unprocessable_entity matters
When validation fails, don’t redirect. Render the form again with status 422.
render :new, status: :unprocessable_entity
That keeps the in-memory object and its errors. If you redirect, you lose the errors and the user has to start over.
Add a custom validation
AI apps often need domain-specific rules. For example, maybe you don’t want documents that are too large for your current embedding pipeline.
app/models/document.rb
class Document < ApplicationRecord
belongs_to :project
validates :title, presence: true, length: { maximum: 120 }
validates :body, presence: true, length: { minimum: 20 }
validates :status, presence: true, inclusion: { in: %w[draft published archived] }
validate :body_not_too_large
private
def body_not_too_large
return if body.blank?
return if body.length <= 10_000
errors.add(:body, "is too large for inline processing")
end
end
That’s a real pattern. Put business rules in the model when they protect the integrity of the record.
A common mistake: trusting the form too much
Never rely on the HTML form alone.
This is not enough:
<%= form.select :status, [["Draft", "draft"], ["Published", "published"]] %>
Why? Because users can still send custom HTTP requests. The browser UI is not a security boundary. The model validation is.
What to practice next
Try these changes:
- Add
validates :title, uniqueness: { scope: :project_id } - Add a checkbox field like
featured:boolean - Permit the new field in
document_params - Add a custom validation that blocks banned words in
title
Once forms and validations click, you’re ready for authentication. That’s where you stop building anonymous demos and start building real applications with users, sessions, and access control.
Top comments (0)