I18n (English + Spanish)
Dependencies
- •i18n (Rails default)
File Structure
code
config/locales/
en/
app.yml # Shared UI, date/time/number formats
articles.yml # Articles domain (views + operations + errors)
users.yml # Users domain
components.yml # Reusable components
models.yml # ActiveRecord models/attributes/enums
es/
app.yml
articles.yml
users.yml
components.yml
models.yml
🔑 Key Principle: Fully-Qualified Keys
Always use fully-qualified translation keys (no relative keys like t(".title")).
Why Fully-Qualified?
- •✅ Explicit: You know exactly where the translation is
- •✅ Grep-able: Easy to find usage with simple text search
- •✅ No magic: No scope resolution confusion
- •✅ Portable: Components/partials work anywhere
- •✅ IDE-friendly: Autocomplete and navigation work better
View Keys (Fully-Qualified)
❌ DON'T use relative keys:
ruby
# app/views/articles/show.rb
h1 { t(".title") } # WRONG - relative key
✅ DO use fully-qualified keys:
ruby
# app/views/articles/show.rb
class Articles::Show < Views::Base
def view_template
h1 { t("articles.show.title") }
p { t("articles.show.description") }
end
end
yaml
# config/locales/en/articles.yml
en:
articles:
show:
title: "Article Details"
description: "View article content"
index:
title: "All Articles"
empty: "No articles yet"
yaml
# config/locales/es/articles.yml
es:
articles:
show:
title: "Detalles del artículo"
description: "Ver contenido del artículo"
index:
title: "Todos los artículos"
empty: "Aún no hay artículos"
Component Keys (Fully-Qualified)
ruby
# app/components/ui/button.rb
class Components::Ui::Button < Components::Base
def view_template(&block)
button(class: "btn") { yield || t("components.ui.button.submit") }
end
end
yaml
# config/locales/en/components.yml
en:
components:
ui:
button:
submit: "Submit"
cancel: "Cancel"
card:
read_more: "Read more"
Operation Success/Failure Messages
Group operation messages under the domain:
ruby
# app/concepts/articles/operation/create.rb
module Articles
module Operation
class Create < Trailblazer::Operation
step :create_article
step :notify_success
def notify_success(ctx, **)
ctx[:message] = I18n.t("articles.operations.create.success")
true
end
end
end
end
yaml
# config/locales/en/articles.yml
en:
articles:
operations:
create:
success: "Article created successfully"
failure: "Failed to create article"
update:
success: "Article updated successfully"
failure: "Failed to update article"
destroy:
success: "Article deleted successfully"
failure: "Failed to delete article"
yaml
# config/locales/es/articles.yml
es:
articles:
operations:
create:
success: "Artículo creado exitosamente"
failure: "Error al crear artículo"
update:
success: "Artículo actualizado exitosamente"
failure: "Error al actualizar artículo"
Error Messages (Domain-Scoped)
Key Principle: Error messages are scoped to domain to allow different messages for same field across models.
Contract Error Messages
ruby
# app/concepts/articles/contract/create.rb
module Articles
module Contract
class Create < Reform::Form
property :title
property :body
validation do
required(:title).filled(:str)
required(:body).filled(:str)
end
# Custom error messages
def self.human_attribute_name(attr, options = {})
I18n.t("articles.attributes.#{attr}", default: attr.to_s.humanize)
end
end
end
end
yaml
# config/locales/en/articles.yml
en:
articles:
attributes:
title: "Title"
body: "Content"
errors:
title_blank: "Article title cannot be blank"
title_too_short: "Article title must be at least 3 characters"
title_taken: "Article with this title already exists"
body_blank: "Content is required"
body_too_short: "Content must be at least 10 characters"
yaml
# config/locales/es/articles.yml
es:
articles:
attributes:
title: "Título"
body: "Contenido"
errors:
title_blank: "El título del artículo no puede estar vacío"
title_too_short: "El título debe tener al menos 3 caracteres"
title_taken: "Ya existe un artículo con este título"
body_blank: "El contenido es obligatorio"
body_too_short: "El contenido debe tener al menos 10 caracteres"
Using Errors in Operations
ruby
# In Operation
def validate(ctx, params:, **)
contract = Articles::Contract::Create.new(Article.new)
if contract.validate(params[:article])
ctx[:model] = contract.model
true
else
# Transform contract errors to domain-scoped i18n keys
ctx[:errors] = contract.errors.messages.transform_values do |messages|
messages.map { |msg| I18n.t("articles.errors.#{msg}") }
end
false
end
end
Model Attributes + Enums
yaml
# config/locales/en/models.yml
en:
activerecord:
models:
article: "Article"
user: "User"
attributes:
article:
title: "Title"
body: "Content"
status: "Status"
user:
name: "Name"
email: "Email"
enums:
article:
status:
draft: "Draft"
published: "Published"
archived: "Archived"
yaml
# config/locales/es/models.yml
es:
activerecord:
models:
article: "Artículo"
user: "Usuario"
attributes:
article:
title: "Título"
body: "Contenido"
status: "Estado"
user:
name: "Nombre"
email: "Correo electrónico"
enums:
article:
status:
draft: "Borrador"
published: "Publicado"
archived: "Archivado"
Use in code:
ruby
Article.model_name.human # => "Article" Article.human_attribute_name(:title) # => "Title" Article.human_enum_name(:status, :draft) # => "Draft"
Date/Time Formatting (Use I18n.l, NOT strftime)
❌ DON'T use strftime:
ruby
article.created_at.strftime("%B %-d, %Y") # WRONG
✅ DO use I18n.l (localize):
ruby
I18n.l(article.created_at, format: :long) # RIGHT I18n.l(article.created_at, format: :short) # RIGHT I18n.l(article.created_at.to_date) # Uses default date format
Define Formats
Define Formats
yaml
# config/locales/en/app.yml
en:
date:
formats:
default: "%Y-%m-%d"
short: "%b %-d"
long: "%B %-d, %Y"
month_year: "%B %Y"
time:
formats:
default: "%a, %d %b %Y %H:%M:%S %z"
short: "%d %b %H:%M"
long: "%B %-d, %Y %H:%M"
time_only: "%H:%M"
yaml
# config/locales/es/app.yml
es:
date:
formats:
default: "%Y-%m-%d"
short: "%-d %b"
long: "%-d de %B de %Y"
month_year: "%B de %Y"
time:
formats:
default: "%a, %d %b %Y %H:%M:%S %z"
short: "%-d %b %H:%M"
long: "%-d de %B de %Y %H:%M"
time_only: "%H:%M"
Usage in Views
ruby
# Phlex view
class Articles::Show < Views::Base
def view_template
div do
h1 { @article.title }
p(class: "text-sm text-gray-500") do
plain "Published: "
plain I18n.l(@article.published_at, format: :long)
end
p(class: "text-sm") do
plain "Last updated: "
plain I18n.l(@article.updated_at.to_date, format: :short)
end
end
end
end
Benefits of I18n.l:
- •✅ Respects user's locale automatically
- •✅ Centralized format definitions
- •✅ Easy to change formats globally
- •✅ Proper localization (e.g., "February 10, 2026" vs "10 de febrero de 2026")
Number/Currency Formatting (Use I18n Helpers)
❌ DON'T format manually:
ruby
"$#{price.round(2)}" # WRONG
✅ DO use number helpers with I18n:
ruby
number_to_currency(price) # => "$1,234.56" (en) or "€1.234,56" (es) number_to_percentage(rate) # => "85.5%" number_with_delimiter(count) # => "1,234,567" number_with_precision(value, precision: 2) # => "123.46"
Currency Format Definitions
yaml
# config/locales/en/app.yml
en:
number:
format:
delimiter: ","
separator: "."
precision: 2
currency:
format:
unit: "$"
format: "%u%n"
delimiter: ","
separator: "."
yaml
# config/locales/es/app.yml
es:
number:
format:
delimiter: "."
separator: ","
precision: 2
currency:
format:
unit: "€"
format: "%n %u"
delimiter: "."
separator: ","
Translation Key Conventions Summary
File Organization
code
config/locales/
en/
app.yml # Shared: date/time/number formats, common UI
{domain}.yml # Domain-specific: views + operations + errors
components.yml # Reusable components
models.yml # ActiveRecord translations
Key Structure by Context
Views:
yaml
{domain}:
{action}:
key: "Translation"
# Example
articles:
show:
title: "Article Details"
index:
title: "All Articles"
Operations:
yaml
{domain}:
operations:
{operation_name}:
success: "Success message"
failure: "Failure message"
# Example
articles:
operations:
create:
success: "Article created"
Errors:
yaml
{domain}:
errors:
{field}_{validation}: "Error message"
# Example
articles:
errors:
title_blank: "Title cannot be blank"
title_too_short: "Title is too short"
Components:
yaml
components:
{namespace}:
{component}:
key: "Translation"
# Example
components:
ui:
button:
submit: "Submit"
Controllers (Flash Messages)
ruby
class ArticlesController < ApplicationController
def create
result = Articles::Operation::Create.call(params: params.to_unsafe_h, current_user: Current.user)
if result.success?
redirect_to articles_path, notice: I18n.t("articles.operations.create.success")
else
flash.now[:alert] = format_errors(result[:errors])
render :new, status: :unprocessable_entity
end
end
private
def format_errors(errors)
errors.full_messages.join(", ")
end
end
Pluralization
yaml
# config/locales/en/articles.yml
en:
articles:
index:
count:
zero: "No articles"
one: "1 article"
other: "%{count} articles"
ruby
# Usage
t("articles.index.count", count: @articles.size)
Interpolation
yaml
# config/locales/en/articles.yml
en:
articles:
show:
author: "Written by %{name}"
published: "Published on %{date}"
ruby
# Usage
t("articles.show.author", name: @article.author.name)
t("articles.show.published", date: I18n.l(@article.published_at, format: :long))
Fallbacks
Enable fallbacks so missing keys fall back to English:
ruby
# config/application.rb config.i18n.available_locales = %i[en es] config.i18n.default_locale = :en config.i18n.fallbacks = [:en]
Best Practices Summary
- •✅ Always use fully-qualified keys:
t("articles.show.title")nott(".title") - •✅ Domain-scope errors:
articles.errors.title_blankfor context-specific messages - •✅ Use I18n.l for dates: Never use
strftime - •✅ Use number helpers:
number_to_currency,number_with_delimiter - •✅ Both languages required: English + Spanish for every key
- •✅ Organize by domain: Keep all article translations in
articles.yml - •✅ Operation messages: Group under
{domain}.operations.{op_name} - •✅ No hardcoded text: Everything user-facing must be i18n
Anti-Patterns
See anti-patterns.md for complete examples.
❌ Don't:
- •Use relative keys:
t(".title") - •Use
strftime:date.strftime("%Y-%m-%d") - •Hardcode currency:
"$#{price}" - •Share error keys across domains without scoping
- •Mix languages in same file
- •Forget Spanish translations