DEV Community

AgentQ
AgentQ

Posted on

Forms & Validations in Rails

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
Enter fullscreen mode Exit fullscreen mode

And the table has these columns:

  • project_id
  • title
  • body
  • status

Now create a controller if you don’t already have one:

bin/rails generate controller Documents new create edit update
Enter fullscreen mode Exit fullscreen mode

Add routes in config/routes.rb:

Rails.application.routes.draw do
  resources :projects do
    resources :documents, only: [:new, :create, :edit, :update]
  end
end
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This gives you three useful protections:

  • no blank titles
  • no tiny document bodies
  • no random status values like done or weird

Test it in the console:

bin/rails console
Enter fullscreen mode Exit fullscreen mode
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"
# ]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The line that matters most:

params.require(:document).permit(:title, :body, :status)
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

Then render it from new.html.erb:

<h1>New Document</h1>
<%= render "form" %>
Enter fullscreen mode Exit fullscreen mode

And from edit.html.erb:

<h1>Edit Document</h1>
<%= render "form" %>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]] %>
Enter fullscreen mode Exit fullscreen mode

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:

  1. Add validates :title, uniqueness: { scope: :project_id }
  2. Add a checkbox field like featured:boolean
  3. Permit the new field in document_params
  4. 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)