VicinoTe: Services and Categories
Episode 2 got accounts working end to end — sign up, sign in, sign out, password reset — and left it there deliberately: nothing touched Service or Booking yet. This episode is where the marketplace actually starts being a marketplace: a signed-in user can list something they offer, and anyone can browse what’s listed.
It’s also where the has_many :services association from episode 1’s domain sketch finally gets written into the User model. It’s been sitting in a blog post as a design decision for two episodes; today it becomes real code.
This is a long one on purpose — the goal is that nothing in the diff is left unexplained: every generated file, every line we added by hand, every option passed to every method.
Code is tagged episode-3 in the vicinote-tutorial repo.
What this episode touches, at a glance
Before going file by file, here’s the map. Two new models, one changed model, one new controller, two new views, a routing change, a seed file, and a one-line translation fix:
db/migrate/..._create_categories.rb new — the categories table
db/migrate/..._create_services.rb new — the services table
app/models/category.rb new — Category: has_many :services, validates :name
app/models/service.rb new — Service: belongs_to :user/:category, validations, price accessor
app/models/user.rb edit — adds has_many :services
app/controllers/services_controller.rb new — index (public), new, create (both signed-in only)
app/views/services/index.html.erb new — the listing page
app/views/services/new.html.erb new — the "offer a service" form
config/routes.rb edit — resources :services, only: [:index, :new, :create]
config/locales/en.yml edit — fixes a leaked internal attribute name in error messages, sets the default currency to euros
db/seeds.rb edit — the fixed list of categories
app/views/pages/home.html.erb edit — the two placeholder buttons now link somewhere
Generating the two models
bin/rails generate model Category "name:string:uniq"
bin/rails generate model Service title:string description:text price_cents:integer user:references category:references
Every field:type pair after the model name becomes a migration column. The syntax has a few extra tricks worth spelling out, since both commands use them:
name:string:uniq— the third segment,:uniq, isn’t a type. It tells the generator to also add a unique index on that column, so it writes anadd_indexcall for us instead of us remembering to add one later.user:referencesandcategory:references—referencesisn’t a column type either, it’s a generator shorthand meaning “this model belongs to that one.” It expands into at.referencescall in the migration (a foreign-key integer column plus an index), and it makes the generator write abelongs_to :user/belongs_to :categoryline directly into the generated model file. That’s whyService’s model already has both associations in it the moment the generator finishes — we didn’t typebelongs_toourselves.
Each command prints what it created:
create db/migrate/20260820055929_create_categories.rb
create app/models/category.rb
invoke test_unit
create test/models/category_test.rb
create test/fixtures/categories.yml
create db/migrate/20260820055946_create_services.rb
create app/models/service.rb
invoke test_unit
create test/models/service_test.rb
create test/fixtures/services.yml
The test_unit files are Rails’ default test scaffolding (an empty test class and an empty fixture file) — this series isn’t using them, so they’re left as generated and not discussed further.
The categories migration, line by line
Generated, then hand-edited to add one word:
# db/migrate/..._create_categories.rb
class CreateCategories < ActiveRecord::Migration[8.1]
def change
create_table :categories do |t|
t.string :name, null: false
t.timestamps
end
add_index :categories, :name, unique: true
end
end
class CreateCategories < ActiveRecord::Migration[8.1]— every migration is a subclass ofActiveRecord::Migration, versioned to the Rails release that generated it ([8.1]). That version pin is what lets Rails change migration DSL behavior across major versions without silently breaking migrations written for an older one.def change— the one method Rails needs. For operations it can reverse automatically (creating a table, adding a column, adding an index),changeis enough; Rails infers how to undo it if you ever roll the migration back. Irreversible operations would need separateup/downmethods instead — nothing here needs that.create_table :categories do |t|— opens the table definition block;tis the object every column gets defined on.t.string :name, null: false— aVARCHARcolumn. The generator wrotet.string :name; thenull: falseis the one word we added by hand, matching the same rigor episode 2 put into theuserstable. Without it, Postgres would happily store a category with no name at all, and that’s not a state the app has any use for.t.timestamps— shorthand for two columns,created_atandupdated_at, bothdatetime, both filled in automatically by ActiveRecord on create/update. Almost every table in this series has this line;Categoryis no exception.add_index :categories, :name, unique: true— this is what:uniqin the generator command produced. It’s a database-level uniqueness guarantee, enforced by Postgres itself, not just by a Rails validation that could in theory be bypassed by two simultaneous requests racing each other.Categoryalso validates uniqueness in the model (below) — belt and suspenders: the model validation gives a friendly error in the normal case, the index guarantees correctness even under a race.
The services migration, line by line
# db/migrate/..._create_services.rb
class CreateServices < ActiveRecord::Migration[8.1]
def change
create_table :services do |t|
t.string :title, null: false
t.text :description, null: false
t.integer :price_cents, null: false
t.references :user, null: false, foreign_key: true
t.references :category, null: false, foreign_key: true
t.timestamps
end
end
end
t.string :title, null: false— same shape asCategory#name: a required short text field.t.text :description, null: false—textinstead ofstring. In Postgres this is really a formality (both map to the same unboundedtexttype under the hood; Rails’stringvstextdistinction is mostly a Rails-level hint, not a Postgres storage difference), but it signals intent: this column holds a paragraph, not a label, and some form helpers (form.text_areainstead ofform.text_field) key off exactly this type later.t.integer :price_cents, null: false— a plain integer. There’s a whole section below on why this is an integer and not adecimal.t.references :user, null: false, foreign_key: true— this line is whatuser:referenceson the command line generated, and it’s doing three things at once:- Adding an integer column named
user_id(the_idsuffix and the pluralization-to-singular are both Rails convention, not something we typed). foreign_key: true— adds an actual Postgres foreign key constraint fromservices.user_idtousers.id. The database itself will now refuse to insert aServicerow whoseuser_iddoesn’t match a real user, and refuses to delete aUserrow that still has services pointing at it (unless the association says otherwise — more on that below).- An index on
user_id, added automatically, because a foreign-key column that isn’t indexed makes every query that joins through it slow as the table grows. This one wasn’t optional or something we asked for —t.referencesalways indexes.null: falsehere means every service must belong to somebody; there’s no such thing as a service with no provider.
- Adding an integer column named
t.references :category, null: false, foreign_key: true— identical shape, for the other side of the relationship.
Notice neither migration says anything about belongs_to or has_many — migrations only describe the database schema (tables, columns, constraints, indexes). Associations are a separate, Ruby-level concept declared in the models, which is the next section.
Running the migrations
bin/rails db:migrate
This runs both pending migrations in timestamp order (categories, then services — services’ foreign key to categories needs the categories table to already exist) and rewrites db/schema.rb to reflect the new state. schema.rb isn’t something to edit by hand; it’s Rails’ cached snapshot of “what the database currently looks like,” regenerated every time you migrate, and it’s what a teammate’s bin/rails db:setup reads to build a fresh database that matches yours without replaying every migration ever written.
The generated models, before any edits
Right after the generator ran, before we touched anything:
# app/models/category.rb
class Category < ApplicationRecord
end
# app/models/service.rb
class Service < ApplicationRecord
belongs_to :user
belongs_to :category
end
Category is empty because it has no references columns — there was nothing for the generator to infer an association from. Service already has both belongs_to lines because of the :references fields we passed on the command line, as explained above. Both inherit from ApplicationRecord, the abstract base class every model in a Rails app shares (itself a thin subclass of ActiveRecord::Base), which is what gives them .find, .create, .where, validations, and everything else ActiveRecord provides — none of that is written in either file, it’s inherited.
One thing worth naming explicitly: as of Rails 5, belongs_to is required by default. Writing belongs_to :user doesn’t just declare the association, it also implicitly adds a presence validation — a Service without a user fails validation, on top of the database already refusing it via the null: false, foreign_key: true from the migration. Two independent layers enforcing the same rule, at two different levels (Ruby validation vs. SQL constraint), which is exactly the “Category must exist” error message you’ll see later in this post — that sentence is Rails’ default wording for a failed belongs_to presence check on category.
Category, filled in
# app/models/category.rb
class Category < ApplicationRecord
has_many :services
validates :name, presence: true, uniqueness: true
end
has_many :services— the other half ofService belongs_to :category. Rails associations are declared on both ends by hand; there’s no way to declare just one side and have the other inferred. This is what makessome_category.serviceswork — the association looks up everyServicerow whosecategory_idmatches, via the foreign key the migration created.validates :name, presence: true, uniqueness: true—presence: trueduplicates what thenull: falsein the migration already guarantees at the database level, but for a different audience: a failed database constraint raises an uglyActiveRecord::NotNullViolationexception, while a failed presence validation gives@category.errorsa friendly message a view can render.uniqueness: trueis the model-level half of the belt-and-suspenders pair with the migration’s unique index — this one runs aSELECTbefore saving and gives a clean error; the index is what actually stops a duplicate from ever reaching the table if two requests race each other past the validation at the same instant.
Service, filled in
# app/models/service.rb
class Service < ApplicationRecord
belongs_to :user
belongs_to :category
validates :title, presence: true
validates :description, presence: true
validates :price_cents, presence: true, numericality: { greater_than: 0, only_integer: true }
# price_cents is what's stored and compared (no float rounding surprises),
# but nobody wants to type cents into a form. This speaks euros on the
# way in and out, so the form field can just be "price".
def price
price_cents && price_cents / 100.0
end
def price=(value)
self.price_cents = value.present? ? (value.to_f * 100).round : nil
end
end
validates :title, presence: true/validates :description, presence: true— same reasoning asCategory#name: the migration’snull: falseis the last line of defense, this is the friendly one that runs first.validates :price_cents, presence: true, numericality: { greater_than: 0, only_integer: true }— three checks bundled into one line.presence: truerejectsnil.numericality:on its own would just reject anything that isn’t a number at all (a string like"abc"); thegreater_than: 0option adds the business rule that a free service isn’t a thing this validation allows, andonly_integer: truerejects fractional cents (4250.5), which shouldn’t be reachable anyway sinceprice_centsis a databaseintegercolumn, but the validation makes the rule explicit rather than relying on the column type alone to enforce it before the value ever reaches the database.def price/def price=— covered in full in the next section; this is the piece that lets a form talk in euros while the column underneath stays cents.
A decision worth slowing down on: price_cents, not price
The generator wrote price_cents:integer, not price:decimal, on the command line — that phrasing was chosen deliberately going in, and it’s the same category of decision episode 1 made about Booking storing its own price rather than reading service.price live: money handled as a float, or even a naive decimal, eventually produces a rounding error that shows up as a few cents off on an invoice, at the worst possible moment. Storing the amount as an integer number of cents sidesteps the whole class of bug — there’s no fractional part to round, because there’s no fraction at all. €42.50 is stored as the integer 4250, full stop. Naming it price_cents rather than, say, price_usd_cents is deliberate too — “cents” is the minor unit of euros just as much as it is of dollars, so nothing about the column itself is tied to a currency; only the formatting layer, covered later in this post, has any idea which one is in use.
The cost is that nobody wants to type “4250” into a form and mean €42.50. So Service gets a small virtual accessor — two plain Ruby methods, not a database column — that translates euros to cents and back:
def price
price_cents && price_cents / 100.0
end
def price=(value)
self.price_cents = value.present? ? (value.to_f * 100).round : nil
end
Walking through both:
def price— reads for display.price_cents && price_cents / 100.0uses Ruby’s&&for its short-circuit behavior, not as a boolean check: ifprice_centsisnil(a brand-new, unsavedService), the whole expression short-circuits tonilwithout attemptingnil / 100.0, which would raise. If it’s a real integer,&&evaluates and returns the right-hand side — the division. Dividing by100.0(a float literal, not100) forces Ruby to do floating-point division rather than integer division, so4250 / 100.0gives42.5, not42truncated.def price=(value)— the setter, called automatically whenever something doesservice.price = "42.50"or, just as automatically, whenever a form submits a field namedpricethrough mass assignment (Service.new(price: "42.50", ...)) — Rails calls the setter method for every permitted attribute, it doesn’t care whether that method backs a real column or not.value.present?guards against blank input (an empty string from a cleared form field) rather than trying to convert""into a number. When there is a value,value.to_f * 100converts to euros-as-a-float and multiplies by 100 to get cents, and.roundturns that back into a whole number —.to_fon user input can produce things like42.499999999999996due to ordinary floating-point imprecision, and.roundis what cleans that back up to exactly4250before it ever reachesprice_cents=.
Because price= is a normal Ruby method and not an ActiveRecord-backed attribute, it runs immediately when attributes are assigned — before save, before any validation. By the time validates :price_cents, ... runs, price_cents has already been populated by this setter. The form field discussed later in this post is just form.text_field :price — the view never has any idea cents exist.
Categories are seeded, not created
A marketplace where anyone can invent a new category ends up with fifty categories that mean the same thing, spelled five different ways, and browsing-by-category stops being useful. VicinoTe curates a fixed list instead, in db/seeds.rb:
# db/seeds.rb
[
"Home Repair",
"Tutoring",
"Cleaning",
"Gardening",
"Pet Care",
"Moving Help",
"Tech Support",
"Beauty & Wellness"
].each do |name|
Category.find_or_create_by!(name: name)
end
- The literal array of eight strings is the list of categories, in plain Ruby, right there in the seed file — no admin UI, no separate config format, just a source-controlled array a future episode could extend by adding a ninth string.
.each do |name| ... enditerates the array once per category.Category.find_or_create_by!(name: name)— this single method does two jobs depending on what it finds: if aCategorywith thatnamealready exists, it returns it untouched; if not, it builds and saves a new one. The trailing!means it raisesActiveRecord::RecordInvalidon a validation failure instead of silently returning an unsaved, invalid record — which matters here, because a seed file failing loudly is much easier to debug than one that fails quietly and leaves the database half-seeded.
That combination — a fixed array plus find_or_create_by! — is what makes the file safe to run more than once. Running db:seed again after adding a ninth category to the array later creates only the new one; the first eight are matched by name and left alone, not duplicated.
bin/rails db:migrate
bin/rails db:seed
db:seed just executes db/seeds.rb as a plain Ruby script inside the app’s environment — nothing more mysterious than that.
Writing the association episode 1 only sketched
Episode 1’s domain design showed this code as an illustration of the “role emerges from the association” decision — the whole point being that a User isn’t tagged with a role: "provider" column, it’s a provider because it has services. It was never actually in the User model, though; episode 2 was about authentication and didn’t touch it. It goes in now:
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
has_many :services, dependent: :destroy
normalizes :email_address, with: ->(e) { e.strip.downcase }
end
Only one line changed — has_many :services, dependent: :destroy was added, everything else (has_secure_password, the sessions association, the email normalization) is untouched from episode 2.
dependent: :destroy matches what sessions already does on the line above it, for the same reason: without it, deleting a User would either fail outright (the database’s foreign_key: true constraint from the migration would reject the delete, since services rows would still point at a user_id that’s about to stop existing) or, if the constraint were relaxed, leave orphaned Service rows in the table forever, pointing at nobody. dependent: :destroy tells Rails to delete every associated Service first, automatically, whenever a User is destroyed — the cleanup is one word, not something every future call site has to remember to do by hand.
The controller: public index, protected everything else
# app/controllers/services_controller.rb
class ServicesController < ApplicationController
allow_unauthenticated_access only: :index
def index
@services = Service.includes(:category, :user).order(created_at: :desc)
end
def new
@service = Current.user.services.new
end
def create
@service = Current.user.services.new(service_params)
if @service.save
redirect_to services_path, notice: "Your service is live."
else
render :new, status: :unprocessable_entity
end
end
private
def service_params
params.require(:service).permit(:title, :description, :price, :category_id)
end
end
Line by line:
class ServicesController < ApplicationController— every controller in a Rails app inherits fromApplicationController, which is where episode 2’sAuthenticationconcern is included. That’s what makes the next line meaningful.allow_unauthenticated_access only: :index— episode 2’sAuthenticationconcern runsbefore_action :require_authenticationfor every action on every controller by default, which means without this line, an anonymous visitor hitting any action here would be redirected straight to the sign-in page. That’s correct fornewandcreate— nobody should be able to list a service anonymously — but wrong forindex: browsing the marketplace has to work for someone who hasn’t signed up yet, or there’s no reason to sign up in the first place.only: :indexopts that one action back out of the requirement while leavingnewandcreateprotected. This is the exact same mechanism episode 2 used, with no argument at all (allow_unauthenticated_access, noonly:), to make the entirePagesControllerpublic — here we only want one of three actions exempted, soonly:narrows it.def index/@services = Service.includes(:category, :user).order(created_at: :desc)—Service.includes(:category, :user)isn’t filtering anything;includesis ActiveRecord’s eager-loading method. Without it, a view that loops over@servicesand callsservice.category.nameandservice.user.email_addressfor each one would fire one additionalSELECTper service, per association — the classic N+1 query problem, where displaying 50 services silently means 101 queries (1 for the list, plus 50 for categories, plus 50 for users) instead of 3.includesloads the categories and users for every service up front, in a small fixed number of extra queries, regardless of how many services there are..order(created_at: :desc)sorts newest-first, so a new listing shows up at the top of the page rather than the bottom.def new/@service = Current.user.services.new— this is the payoff of wiringhas_many :servicesontoUsera section ago.Current.useris the currently authenticated user (from episode 2’sCurrentmodel, anActiveSupport::CurrentAttributessubclass). Calling.services.newthrough the association, rather thanService.newon its own, pre-fills the new record’suser_idwithCurrent.user.idautomatically — the instance this line builds already knows who it belongs to before a single form field has been filled in.def create—Current.user.services.new(service_params)does the same association-scoped build asnew, but this time passing in the submitted form data as well. Because theuserside of the association is set byCurrent.user.services, not by anything inservice_params, there’s nouser_idfield anywhere in the permitted params below for a malicious visitor to tamper with and claim a listing on someone else’s behalf — the provider is whoeverCurrent.usersays it is, full stop, not whatever a hidden form field might claim.if @service.save—saveruns validations and, if they all pass, performs theINSERTand returnstrue; if any validation fails, it does nothing to the database and returnsfalse— no exception raised, which is why this is a plainif, not abegin/rescue.redirect_to services_path, notice: "Your service is live."— on success, a real HTTP redirect to the index (this is the same Post/Redirect/Get pattern episode 3 of ai-with-ruby discusses in more depth: redirecting after a state-changing POST means refreshing the result page never re-submits the form).notice:stores a one-time message in theflash— it survives exactly one redirect and then clears itself, which is how “Your service is live.” shows up on the very next page load and is gone if you refresh again.render :new, status: :unprocessable_entity— on failure, re-render the same form (not redirect anywhere) so@service— now populated with both what the user typed and the validation errors attached to it — can be shown back to them with their input intact and the specific problems called out.status: :unprocessable_entitysets the HTTP status code to 422 instead of the default 200; the browser still renders the HTML either way, but a 422 correctly tells any tooling watching the response (browser dev tools, Turbo, a test suite) that this was a failed submission, not a successful page load that happens to contain a form.private— everything below this line is only callable from within the controller itself, not reachable as a route.def service_params/params.require(:service).permit(:title, :description, :price, :category_id)— Rails’ strong parameters.params.require(:service)raises immediately if the submitted form data has no top-levelservicekey at all (a malformed or missing request), and.permit(...)is an allowlist: only these four keys are allowed through; anything else present in the raw request params — including, say, auser_idsomeone tried to inject by editing the form’s HTML in their browser before submitting — is silently stripped and never reachesService.new. Notice this permits:price, not:price_cents— the controller talks to the same euros-facing interface the form does; it has no ideaprice_centsexists either.
Routes and the two placeholder buttons
# config/routes.rb
resources :services, only: [:index, :new, :create]
resources :services is Rails’ RESTful routing shorthand — on its own it would generate all seven conventional routes (index, new, create, show, edit, update, destroy). only: [:index, :new, :create] narrows that down to exactly the three this episode implements. Running bin/rails routes -g services shows exactly what got generated:
Prefix Verb URI Pattern Controller#Action
services GET /services(.:format) services#index
POST /services(.:format) services#create
new_service GET /services/new(.:format) services#new
The Prefix column is where path helper names like services_path and new_service_path — used throughout the controller and views in this episode — come from: Rails derives them automatically from the prefix plus _path or _url. There’s no show route yet, on purpose — there’s no individual service page to link to until a later episode builds one.
Episode 1’s landing page shipped with two buttons that did nothing on purpose, styled to look disabled:
<span class="rounded-md bg-indigo-600 px-4 py-2 text-white font-medium opacity-60 cursor-not-allowed">
Browse services
</span>
<span class="rounded-md border border-gray-300 px-4 py-2 text-gray-700 font-medium opacity-60 cursor-not-allowed">
Offer a service
</span>
They finally go somewhere:
<%= link_to "Browse services", services_path, class: "rounded-md bg-indigo-600 px-4 py-2 text-white font-medium hover:bg-indigo-700" %>
<%= link_to "Offer a service", authenticated? ? new_service_path : new_registration_path, class: "rounded-md border border-gray-300 px-4 py-2 text-gray-700 font-medium hover:bg-gray-50" %>
Both <span> placeholders became link_to calls, and the opacity-60 cursor-not-allowed classes that visually signalled “not clickable yet” are gone along with the disabled state itself.
link_to "Browse services", services_path, ...— unconditional. The index is public (allow_unauthenticated_access only: :indexfrom the controller), so this link always makes sense, signed in or not.link_to "Offer a service", authenticated? ? new_service_path : new_registration_path, ...— the destination itself is a ternary.authenticated?is thehelper_methodepisode 2’sAuthenticationconcern exposes to views (it’s justresume_session, reused to answer “is anyone signed in” without triggering a redirect the wayrequire_authenticationwould). Signed in, the link goes straight tonew_service_path— the form. Signed out, it goes tonew_registration_path— sign-up — instead of straight to the service form, which would just immediately bounce them to the sign-in page anyway viarequire_authentication. Same eventual destination either way; one fewer redirect for the common case of a visitor who isn’t signed in yet.
The “offer a service” form
<%# app/views/services/new.html.erb %>
<div class="max-w-xl mx-auto">
<h1 class="text-3xl font-bold text-gray-900">Offer a service</h1>
<p class="mt-2 text-gray-600">Tell your neighbours what you can help with.</p>
<%= form_with model: @service, url: services_path, class: "mt-8 space-y-5" do |form| %>
<% if @service.errors.any? %>
<div class="rounded-md bg-red-50 px-4 py-3 text-sm text-red-700">
<ul class="list-disc list-inside">
<% @service.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div>
<%= form.label :title, class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :title, placeholder: "Guitar lessons for beginners", class: "mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500" %>
</div>
<div>
<%= form.label :category_id, "Category", class: "block text-sm font-medium text-gray-700" %>
<%= form.collection_select :category_id, Category.order(:name), :id, :name, { prompt: "Choose a category" }, class: "mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500" %>
</div>
<div>
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :description, rows: 4, placeholder: "What you offer, your experience, anything a neighbour should know.", class: "mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500" %>
</div>
<div>
<%= form.label :price, "Price (EUR)", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :price, placeholder: "45.00", inputmode: "decimal", class: "mt-1 block w-40 rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500" %>
</div>
<%= form.submit "Publish", class: "rounded-md bg-indigo-600 px-4 py-2 text-white font-medium hover:bg-indigo-700 cursor-pointer" %>
<% end %>
</div>
form_with model: @service, url: services_path, ...—model: @serviceis what makes this one form work for both a brand-new, unsavedService(fromServicesController#new) and, later, one that failed to save and is being re-rendered with errors attached (from#create’srender :new) — Rails inspects whether the record is persisted to decide the form’s HTTP method, though since the routes only definecreate, this form only ever needs to POST.url: services_pathis explicit here rather than left to be inferred, pointing the submission at theservices#createroute regardless.<% if @service.errors.any? %> ... @service.errors.full_messages.each ... <% end %>— after a failedcreate,@servicecarries whatever the user typed and the specific validation failures attached to it bysave.errors.full_messagesturns those into ready-to-read strings like “Title can’t be blank” — this is exactly the block that rendered the error lists shown further down this post.form.label :title, .../form.text_field :title, ...— a standard labeled text input.form.label :titlewithout an explicit label text infers “Title” from the attribute name automatically (via the samehumanizemechanism discussed in the I18n section below);placeholder:is just an HTML attribute passed straight through.form.collection_select :category_id, Category.order(:name), :id, :name, { prompt: "Choose a category" }, class: "..."— this is the one helper in the form worth slowing down on, because it takes five separate arguments before the HTML options hash::category_id— the attribute being set on submit (matchesbelongs_to :category’s foreign key column).Category.order(:name)— the collection of records to build<option>tags from, alphabetized so the dropdown doesn’t list categories in whatever order they happened to be seeded.:id— the value method: for eachCategoryin the collection, call.idto get what actually gets submitted ascategory_id.:name— the text method: call.nameto get what’s displayed to a human inside the dropdown.{ prompt: "Choose a category" }— options for the select itself;prompt:inserts a disabled, unselected placeholder option with that text, so the dropdown doesn’t silently default to the first category in the list if someone submits without touching it. Only after that fifth argument does the ordinaryclass: "..."HTML-attributes hash appear — acollection_selectcall always separates “how to build the options” from “what HTML attributes to put on the<select>tag” this way.
form.label :price, "Price (EUR)", ...— here the label text is given explicitly (“Price (EUR)”), overriding whathumanize-from-attribute-name would have produced (“Price”), because the form needs to communicate the currency and the model attribute name has no way to carry that on its own.form.text_field :price, placeholder: "45.00", inputmode: "decimal", ...— this is the field that callsService#price=on submit, notprice_cents=— the view genuinely never mentions cents anywhere.inputmode: "decimal"is a plain HTML attribute (nothing Rails-specific) that hints mobile keyboards to show a numeric keypad with a decimal point instead of the full alphabetic keyboard.form.submit "Publish", ...— renders an<input type="submit">with the given label;cursor-pointeris purely cosmetic, since a submit button is clickable by default but browsers don’t always render the pointer cursor on it without being told to.
The services listing
<%# app/views/services/index.html.erb %>
<div class="max-w-3xl mx-auto w-full">
<% if notice = flash[:notice] %>
<p class="py-2 px-3 bg-green-50 mb-5 text-green-700 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
<% end %>
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold text-gray-900">Services near you</h1>
<% if authenticated? %>
<%= link_to "Offer a service", new_service_path, class: "rounded-md bg-indigo-600 px-4 py-2 text-white font-medium hover:bg-indigo-700" %>
<% end %>
</div>
<% if @services.none? %>
<p class="mt-8 text-gray-500">No services yet — be the first to offer one.</p>
<% else %>
<div class="mt-8 space-y-4">
<% @services.each do |service| %>
<div class="rounded-lg border border-gray-200 p-5">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-indigo-600"><%= service.category.name %></p>
<h2 class="mt-1 text-lg font-semibold text-gray-900"><%= service.title %></h2>
<p class="mt-1 text-sm text-gray-500">by <%= service.user.email_address %></p>
</div>
<p class="whitespace-nowrap text-lg font-semibold text-gray-900">
<%= number_to_currency(service.price) %>
</p>
</div>
<p class="mt-3 text-gray-600"><%= service.description %></p>
</div>
<% end %>
</div>
<% end %>
</div>
<% if notice = flash[:notice] %> ... <% end %>— this is a single=on purpose, an assignment used as a condition, not a==comparison.flash[:notice]is read once, assigned to a localnotice, and that same local is reused inside the block — this is the exact pattern already used insessions/new.html.erbfrom episode 2, kept consistent here rather than introducing a different idiom. It’s what makes the “Your service is live.” message from the controller’sredirect_to ..., notice: "..."actually show up on screen — nothing rendersflashautomatically anywhere in this layout, each view that wants to show it does so explicitly.<% if authenticated? %> ... <% end %>around the “Offer a service” button — a signed-out visitor browsing the public index sees the listings but not an invitation to post one; the button only appears once someone is actually signed in.<% if @services.none? %> ... <% else %> ... <% end %>—.none?is a plain ActiveRecord/Enumerable query, true when the relation has zero records. This is the empty-state message, shown instead of an empty page with no explanation the first time the app runs with no listings yet.<% @services.each do |service| %>— iterates the eager-loaded relation from the controller. BecauseService.includes(:category, :user)already pulled every category and user into memory alongside the services themselves,service.category.nameandservice.user.email_addressinside this loop don’t trigger any additional queries — this is the payoff of theincludescall discussed in the controller section landing here, in the view, where the N+1 would otherwise actually happen.service.category.name,service.title,service.user.email_address,service.description— straightforward attribute and association reads.number_to_currency(service.price)— a Rails view helper (fromActionView::Helpers::NumberHelper) that formats a plain number as currency:42.5becomes"€42.50"— the correct two decimal places and currency symbol, not whatever number of digits the float happens to have. This callsservice.price, the euros-facing virtual reader defined on the model — notservice.price_cents— so the number on screen is genuinely42.5, formatted, never4250.
Left completely unconfigured, number_to_currency defaults to US dollars — VicinoTe’s currency is euros, so that default needed overriding once, globally, rather than passing unit: "€" at every call site:
# config/locales/en.yml
en:
number:
currency:
format:
unit: "€"
format: "%u%n"
unit: is the symbol itself. format: is the template that arranges it relative to the number — %u is the unit, %n is the formatted number, so "%u%n" means “symbol, then number, no space,” which is what produces €42.50 rather than € 42.50 or 42.50€. Both number_to_currency calls in the app — there’s only the one, in index.html.erb — pick this up automatically with no argument changes needed, because the default itself moved, not the call site.
A views-only gotcha: the error said “price cents”
Submitting the form empty for the first time, before fixing this, produced:
Category must exist
Title can't be blank
Description can't be blank
Price cents can't be blank
Price cents is not a number
Everything else reads naturally — “Title can’t be blank” — because Rails derives the human-readable label from the attribute name via humanize. But the attribute actually being validated is price_cents, not price, so that’s the name that leaked into the message. A user filling out this form has never heard of price_cents; the form field right below the error just says “Price (EUR)”.
The fix is a one-line I18n override, not a code change to the model — the validation is correct, only its rendered name was wrong:
# config/locales/en.yml
en:
activerecord:
attributes:
service:
price_cents: "Price"
This is Rails’ I18n lookup for human_attribute_name: before falling back to auto-humanize-ing an attribute name, ActiveRecord checks activerecord.attributes.<model>.<attribute> in the locale files, and uses whatever string is found there verbatim, for every message that mentions that attribute — validation errors, form.label with no explicit text, anywhere.
One detail that cost a second pass: writing the override as price (lowercase) produced “price can’t be blank” — lowercase, inconsistent with “Title” and “Description” right next to it. Rails’ default humanize capitalizes the first letter automatically as part of what it does; a custom I18n string is used exactly as written, with no capitalization applied on top of it. The fix was just capitalizing the override itself, "Price".
Trying it
bin/dev
Sign up (or sign in), click “Offer a service”, fill in a title, pick a category, write a description, and a price like 42.50. Submit, and it redirects to /services with “Your service is live.” in a flash banner, the listing right below it — category, title, whoever posted it, description, and €42.50, not 4250. Sign out and visit /services directly: still there, still public. Try /services/new while signed out and it redirects to sign-in, same as any other protected page. Submit the form with everything blank and every field’s own validation message shows up, in plain, correctly-capitalized English, “price_cents” nowhere in sight.
What’s next
Episode 4 builds Booking — the record of an agreement between two users, and the flow that actually lets someone book a service that’s been listed.