Software localization
A Guide to Localizing Rails Active Record Models
Ruby on Rails needs no introduction. Though the Model-View-Controller (MVC) web framework has lost some of its star power in recent years, Rails is still an excellent choice for agile web development. Ruby’s elegant, expressive syntax, combined with Rails’ philosophy of convention over configuration, creates a fluid developer experience that focuses on solving business problems rather than low-level technical details.
When it comes to learning Rails internationalization (i18n), the official guide is an excellent start. So is our own article, A Comprehensive Rails I18n Guide. These guides cover i18n configuration as well as localizing different parts of your Rails app. In this article, we pick up where these guides leave off, and tackle the all-too-important topic of localizing Rails Active Record models. We’ll cover how to localize the actual content of your app stored in the database.
Two Ways to Localize Rails Models
Two effective and popular approaches to localizing models are:
- Adding the localized attributes directly to each model e.g.
title_en
,title_fr
. - Adding a separate translation model for each base model e.g.
Article
andArticleTranslation
, where each article has many article translations.
Our Demo App
The best way to learn the pros and cons of each of the two approachs is to see them in action. Let’s build a simple demo Rails app and localize its models, first using the basic on-model approach, then using the more scalable separate translation model approach. Our app will be a tribute to one of the greatest electronic music duos of all time, Röyksopp.
🗒 Note » It is objectively true that Röyksopp is 🔥. I mean have you heard Church (Lost Tapes)? You just can’t argue with tracks that hot.
Our app with all its model content translated to French
🔗 Resource » You can get all the code for our demo app from GitHub.
Alright, let’s get building. We’re using the following stack at time of writing (versions in parentheses).
- Ruby (3.0)
- Rails (6.1)
🗒 Note » In case you want to code along with us, or are wondering what CSS framework we’re using: it’s Bulma with the Darkly theme from Bulmaswatch.
We’ll spin up a new rails app from the command line.
$ rails new royksopp-forever
Our app has two associated models: an album has many songs. Let’s get these built out.
# Generate the Album model and database table $ bin/rails g model Album image_url title review $ bin/rails db:migrate # Generate the Song model and database table $ bin/rails g model Song album:references order_in_album:integer title $ bin/rails db:migrate
🗒 Note »
bin/rails g
is just a shorthand alias forbin/rails generate
This will have created the albums
and songs
tables, and their respective Active Record models, Album
and Song
. Our Song
will have its belongs_to: :album
association generated. We’ll have to add the inverse association to Album
ourselves.
class Album < ApplicationRecord has_many :songs, -> { order "order_in_album" } end
To ensure that we’re always sorting songs by their album order, we’ve added a little scope to our model association.
We’ll want some data in our models while we develop, so let’s seed the database.
Song.destroy_all Album.destroy_all the_understanding = Album.create( title: "The Understanding", review: "Lush synths, silky builds. This is the first time in a while that a group's gone into the studio and come back doing exactly what I wanted them to.", image_url: "/img/the_understanding.jpg" ) the_understanding.songs.create( order_in_album: 1, title: "Triumpant" ) the_understanding.songs.create( order_in_album: 2, title: "Only This Moment" ) # ... the_inevitble_end = Album.create( title: "The Inevitble End", image_url: "/img/the_inevitble_end.jpg") the_inevitble_end.songs.create( order_in_album: 1, title: "Skulls" ) the_inevitble_end.songs.create( order_in_album: 2, title: "Monument (T.I.E. Version)" ) # ...
🗒 Note » The above review of Röyksopp’s The Understanding album is paraphrased from Nitsuh Abebe’s Pitchfork review.
Of course, we’ll have to run the seed command from the command line to get the data in our database.
$ bin/rails db:seed
Now we can wire up our controller and render this data to the browser.
Rails.application.routes.draw do root "albums#index" end
class AlbumsController < ApplicationController def index @albums = Album.includes(:songs).all end end
We’re avoiding the n + 1 problem by explicitly including our songs association when loading our albums.
Now to our simple views.
<%# ... %> <div class="columns"> <% @albums.each do |album| %> <div class="column"> <%= render "album_front_matter", { album: album } %> <%= render "track_listing", { album: album } %> </div> <% end %> </div>
<figure> <img src="<%= album.image_url %>"> </figure> <h2><%= album.title %></h2> <blockquote> <%= album.review %> </blockquote>
<%# ... %> <table> <%# ... %> <tbody> <% album.songs.each do |song| %> <tr> <td><%= song.order_in_album %></td> <td><%= song.title %></td> </tr> <% end %> </tbody> </table>
That’s about it for our little app. Our above code gets us this little beauty.
🔗 Resource » If you want a snapshot fo the complete app code at this point, check out the start branch from our GitHub repo.
i18n Configuration
Before we get to localizing our models, let’s quickly configure our app’s i18n settings. Let’s say we want to support English and French in our app, with English as our default locale.
require_relative "boot" require "rails/all" Bundler.require(*Rails.groups) module RailsI18nActiveRecord class Application < Rails::Application config.load_defaults 6.1 I18n.available_locales = [:en, :fr] I18n.default_locale = :en end end
✋🏽 Heads up » Remember to restart the Rails dev server after changing config files.
We’ll also want a way to set the locale. One of the simplest ways to do this is having a ?locale=fr
query parameter that we use to set the app locale in our root controller.
class ApplicationController < ActionController::Base around_action :switch_locale def switch_locale(&action) locale = params[:locale] || I18n.default_locale I18n.with_locale(locale, &action) end end
Now we can put strings in our translation files and use them in our views.
en: app_name: "Röyksopp Forever" tagline: "A roundup of awesome Röyskopp albums"
fr: app_name: "Röyksopp Pour Toujours" tagline: "Un tour d'horizon des superbes albums de Röyskopp"
<h1><%= t(:app_name) %></h1> <p><%= t(:tagline) %></p>
When we load our app from http://localhost:3000/?locale=en
we get English UI. /?locale=fr
yields a French UI.
Rails makes it almost trivial to set up i18n
Alright, that’s about all the i18n config we’ll be doing here. Let’s get to localizing our models.
🔗 Resource » i18n config and UI localization are covered in much more detail in our article, The Last Rails I18n Guide You’ll Ever Need.
Simple Localization: Localized Attributes on the Model
We often have apps that need to be localized into only two languages. For such a small number of locales, a simple on-model localization solution can do the trick. The basic idea here is to take every translatable attribute and convert it into two attributes, one for each language. In our case we want to present our content in English and French, so we should aim for models that look like the following.
+------------------+ +------------------+ | Album | | Song | +------------------+ +------------------+ | + image_url | | + album_id | | + title_en | | + order_in_album | | + title_fr | | + title_en | | + review_en | | + title_fr | | + review_fr | | | +------------------+ +------------------+
Notice that each translatable string attribute, like title
, has been replaced with two translations, title_en
and title_fr
, for English and French respectively.
Migrating the Data
A series of migrations can transform our existing models into ones like the above.
$ bin/rails g migration AddTranslationsToSong title_en title_fr
The Rails-generated migration will get us partway there.
class AddTranslationsToSongs < ActiveRecord::Migration[6.1] def change add_column :songs, :title_en, :string add_column :songs, :title_fr, :string end end
However, we might not to lose our existing title data, which we know is in English. We can manually amend the migration before we run it to copy the data to our new column.
class AddTranslationsToSongs < ActiveRecord::Migration[6.1] def change add_column :songs, :title_en, :string add_column :songs, :title_fr, :string Song.update_all("title_en=title") end end
This will preserve our English translations. To clean up, let’s remove the soon unused title
attribute.
$ bin/rails g migration RemoveTitleFromSong
Adding one line to the generated migration gets us nice and tidy.
class RemoveTitleFromSongs < ActiveRecord::Migration[6.1] def change remove_column :songs, :title, :string end end
Now we can migrate.
$ bin/rails db:migrate
Of course, we’ll want to repeat the process for the Album
model. Here’s the migration:
class AddTranslationsToAlbums < ActiveRecord::Migration[6.1] def change add_column :albums, :title_en, :string add_column :albums, :title_fr, :string add_column :albums, :review_en, :string add_column :albums, :review_fr, :string Album.update_all("title_en=title") Album.update_all("review_en=title") end end
🗒 Note » Don’t forget to remove the
title
andreview
attributes fromAlbum
anddb:migrate
when you’re done.
✋🏽 Heads up » You might also want to go back and update your
db/seeds.rb
to use the new localized attributes.
Of course, if we were to run our app now we would get errors. This is because our views are expecting Song#title
, Album#title
, etc. We probably want to keep our views and controllers as they are, since it should be the model’s responsibility to handle the translation resolution. Let’s work some magic in the model layer to get things working again.
A Translatable Concern
We don’t want to repeat the code that resolves translations for each model. One way to keep things DRY (Don’t Repeat Yourself) is to use Rail’s model concerns. We can just add a file under app/models/concerns
to hold our attribute translation logic.
module Translatable extend ActiveSupport::Concern included do def self.translates(*attributes) attributes.each do |attribute| define_method(attribute) do translation_for(attribute) end end end end def translation_for(attribute) read_attribute("#{attribute}_#{I18n.locale}") || read_attribute("#{attribute}_#{I18n.default_locale}") end end
We've extended the ActiveSupport::Concern
module to gain access to the included
method, which allowed us to define class methods in models that mix in this concern. translates
is our class method here, and we'll use it to define which attributes are translated in our models in a moment.
translates
takes an unlimited number of attribute names as symbols or strings. For each one, it defines a method on the model. This means that a call like translates(:title)
will cause a title
method to be defined on the model.
When called, title
will return the value of the translation_for
method. translation_for
first reads the translated attribute in the active locale. If the current request is /?locale=fr
, the active locale will be fr
, so translation_for(:title)
will attempt to read the title_fr
attribute.
If title_fr
has a truthy value, its value is returned. If title_fr
is undefined, however, translation_for
will fall back to the configured default locale. This is en
in our case, so if title_fr
is nil
, translation_for
will return the value of title_en
.
We can now use our concern in our Song
and Album
models to provide title
and review
methods that automatically return translations in the active locale.
class Song < ApplicationRecord include Translatable belongs_to :album translates :title end
class Album < ApplicationRecord include Translatable has_many :songs, -> { order "order_in_album" } translates :title, :review end
That’s it, really. We don’t need to change any other code. Our app will now work as before, except our Album
and Song
models will present attribute values translated into the active locale.
# In our views, if locale=:en <%= album.title %> # => "The Understanding" # If locale=:fr <%= album.title %> # => "La compréhension"
Overriding Translated Attributes
Sometimes we want to override model attributes to mutate their values before we return them. Our concern makes this straightforward with the translation_for
method. Say, for example, that we wanted to wrap our album reviews with asterisks.
class Album < ApplicationRecord include Translatable has_many :songs, -> { order "order_in_album" } translates :title, :review def review original_review = translation_for(:review) || "" "*#{original_review}*" end end
Overriding the review
method and using translation_for(:review)
to retrieve its translated value allows us to manipulate it however we want before returning it.
# In our views, if locale=:en <%= album.review %> # => "*Lush synths, silky builds...*" # If locale=:fr <%= album.review %> # => "*Synthés luxuriants, constructions...*"
🔗 Resource » Get the complete code for the simple on-model solution from GitHub.
Pros & Cons of On-Model Localization
This simple solution for translating models can go a long way for apps that support 2-3 languages. The main drawback of this solution is that it doesn’t scale gracefully: our database tables can get messy quickly as our translatable column count increases. For example, if our app supports 4 languages and a table has 12 translatable columns, we would have 48 columns on that table.
On the plus side, on-model localization doesn’t add any SQL queries above what we would normally make without localization. As we will see shortly, when we break translations into their own tables we have to worry about the n + 1 problem when retrieving translations. On-model localization doesn’t suffer from these issues.
A related positive for on-model localization is that querying translations is as straightforward as any other queries. We don’t need any joins to retrieve albums that have French titles with the word “Inévitable” in them, for example.
🔗 Resource » If you want a gem that gives you on-model localization like the above, with more functionality, check out Traco.
Scalable Localization: Separate Translation Models
When our app needs to support more than a few locales, and/or if we have a lot of translatable fields on our models, we might want a solution that scales as we add locales. One way to do this is to break out our translations so that each locale for each model has its own record.
Let’s build out this separate translation model solution. We’ll start from the point where our app had non-localized models. You’ll remember that they looked like the following.
+------------------+ +------------------+ | Album | | Song | +------------------+ +------------------+ | + image_url | | + album_id | | + title | | + order_in_album | | + review | | + title | +------------------+ +------------------+
In order support any number of locales, we can aim to transform our models to look like this:
+------------------+ +------------------+ | Album | | Song | +------------------+ +------------------+ | + image_url | | + album_id | | | | + order_in_album | +------------------+ +------------------+ ⬍ ⬍ +------------------+ +------------------+ | AlbumTranslation | | SongTranslation | +------------------+ +------------------+ | + album_id | | + song_id | | + locale | | + locale | | + title | | + title | | + review | | | +------------------+ +------------------+
For each base model, we’ll create a *Translation
model and move all translatable attributes into it. Notice the locale
attribute in the translation models. This will hold a locale code, like "en"
or "fr"
.
With this structure, each locale will have its own translation record. For example, our French translations for a song with an ID of 1
would look like #<SongTranslation id: 2, song_id: 1, locale: "fr", title: "Victorieux" ...>
. Our English translations would have their own record we well. And, of course, we could add as many locales as we wanted to.
Migrating the Data
Alright, let’s get to building. Remember that we’re starting from non-localized models, so we’ll need to add the new translation models and copy the English values over. Let’s generate some models and migrations.
$ bin/rails g model AlbumTranslation album:references locale title review
We’ll need to manually amend the generated migration to copy over our existing English values.
class CreateAlbumTranslations < ActiveRecord::Migration[6.1] def change create_table :album_translations do |t| t.references :album, null: false, foreign_key: true t.string :locale t.string :title t.string :review t.timestamps end # Copy over existing English translations Album.all.each do |album| original_title = album.title original_review = album.review album.translations.create( locale: "en", title: original_title, review: original_review) end end end
The Rails generator will have created a new translation model for us, which we can leave as-is.
class AlbumTranslation < ApplicationRecord belongs_to :album end
We will need to add the translations association to our Album
ourselves, however.
class Album < ApplicationRecord has_many :translations, class_name: "AlbumTranslation" has_many :songs, -> { order "order_in_album" } end
The translations
association is temporary and we’ll just use it for migration. We’ll remove it once we have a translatable concern that we mix into our models a bit later.
Now we can migrate, knowing that our English translations will copy over to our new model.
$ bin/rails db:migrate
And of course, we’ll want to clean up by removing the title
and review
attributes from the Album
model.
$ bin/rails g migration RemoveTitleAndReviewFromAlbums
class RemoveTitleAndReviewFromAlbums < ActiveRecord::Migration[6.1] def change remove_column :albums, :title, :string remove_column :albums, :review, :string end end
$ bin/rails db:migrate
🗒 Note » We need to repeat this process for the
Song
model. I’ve omitted the code for that here so that I don’t bore you.
A Translatable Concern
Our app isn’t in a working state at the moment, since we haven’t updated our models to use the new localization structure. Let’s create a model concern to handle localization as we did above when building our simple localization solution.
module Translatable extend ActiveSupport::Concern included do @translation_model = "#{self}Translation" has_many :translations, class_name: @translation_model has_one :current_translation, -> { where locale: I18n.locale }, class_name: @translation_model has_one :default_translation, -> { where locale: I18n.default_locale }, class_name: @translation_model def self.translates(*attributes) attributes.each do |attribute| define_method(attribute) do translation_for(attribute) end end end end def translation_for(attribute) if current_translation == nil default_translation.send(attribute) else current_translation.send(attribute) end end end
This Translatable
concern is a bit more involved than the one we built for simple localization, but it’s still largely straightforward.
We’ve extended ActiveSupport::Concern
to gain access to the include
method, which allows us to mix in code at the class level into our models. This has allowed us to specify three Active Record associations for a translatable model:
translations
retrieves all translation records associated with the model.current_translation
retrieves the translation record associated with the model that corresponds to the active locale. If the request was/?locale=fr
,current_translation
would retrieve the translation recordwhere(locale: "fr")
.default_translation
retrieves the translation record associated with the model that corresponds to the configured default locale. Recall that we configuredI18n.default_locale
to be:en
earlier. So in our case,default_translation
will always retrieve the translation recordwhere(locale: :en)
.
We can use these associations whenever we need them. However, we also want to allow album.title
to return the translation in the active locale without needing to write album.current_translation.title
. To accomplish this, we also provide the translates
class method for translatable models.
translates
takes any number of attributes as strings or symbols. For each one, it defines a method named after the attribute that returns the translated value for that attribute. So calling translates :review
will create a review
method on the model that returns the translated review.
To get the translated value for an attribute, translates
makes use of translation_for
, an instance method that our concern also provides to translatable models. translation_for
uses the associations we defined earlier to return the translation for the given attribute in the active locale/language. If there is no translation for the attribute in the active locale, translation_for
falls back to the configured default locale.
We can now update our models to make use of our new concern.
class Album < ApplicationRecord include Translatable has_many :songs, -> { order "order_in_album" } translates :title, :review end
Note that we’ve removed the translations
association that we added to the Album
model directly when we were migrating the database. We don’t need it since our concern provides its own translations
association.
class Song < ApplicationRecord include Translatable belongs_to :album translates :title end
And we’re set. Our app should now work exactly as it did before, except our model content is now internationalized and will be presented in the active locale.
# In our views, if locale=:en <%= album.title %> # => "The Understanding" # If locale=:fr <%= album.title %> # => "La compréhension"
Updating Database Seeds
To have some data to look at, let’s update our database seeds to use the new model.
SongTranslation.destroy_all Song.destroy_all AlbumTranslation.destroy_all Album.destroy_all the_understanding = Album.create( image_url: "/img/the_understanding.jpg" ) the_understanding.translations.create( locale: "en", title: "The Understanding", review: "Lush synths, silky builds...", ) the_understanding.translations.create( locale: "fr", title: "La Compréhension", review: "Synthés luxuriants, constructions soyeuses...", ) tu_song_1 = the_understanding.songs.create(order_in_album: 1) tu_song_1.translations.create(locale: "en", title: "Triumphant") tu_song_1.translations.create(locale: "fr", title: "Victorieux") # ...
Performance & the n+1 Problem
One issue with our current solution is that we’ve created the n+1 problem through our translation associations. If we look at our server log when we load a page with translations, we may see something like the following.
Parameters: {"locale"=>"fr"} Song Load (0.3ms) SELECT "songs".* FROM "songs" WHERE "songs"."album_id" IN (?, ?) ORDER BY order_in_album [[nil, 1], [nil, 2]] SongTranslation Load (0.1ms) SELECT "song_translations".* FROM "song_translations" WHERE "song_translations"."song_id" = ? AND "song_translations"."locale" = ? LIMIT ? [["song_id", 1], ["locale", "fr"], ["LIMIT", 1]] SongTranslation Load (0.1ms) SELECT "song_translations".* FROM "song_translations" WHERE "song_translations"."song_id" = ? AND "song_translations"."locale" = ? LIMIT ? [["song_id", 2], ["locale", "fr"], ["LIMIT", 1]] SongTranslation Load (0.1ms) SELECT "song_translations".* FROM "song_translations" WHERE "song_translations"."song_id" = ? AND "song_translations"."locale" = ? LIMIT ? [["song_id", 3], ["locale", "fr"], ["LIMIT", 1]] ... SongTranslation Load (0.2ms) SELECT "song_translations".* FROM "song_translations" WHERE "song_translations"."song_id" = ? AND "song_translations"."locale" = ? LIMIT ? [["song_id", 29], ["locale", "fr"], ["LIMIT", 1]]
For each song we load, we make a separate SELECT
query for its translation in the active locale. This can lead to very poor performance.
Thankfully, the n + 1 problem is easily addressed with a little eager loading. We can specify a default scope on each of our translated models that includes the model’s translations.
class Album < ApplicationRecord include Translatable has_many :songs, -> { order "order_in_album" } translates :title, :review default_scope { includes :current_translation } end
class Song < ApplicationRecord include Translatable belongs_to :album translates :title default_scope { includes :current_translation } end
🗒 Note » Depending on who you ask, default scopes are considered a bad idea. The main argument against them is that you can forget they’re set on a model and wonder why your queries aren’t working as expected. In this case, however, we almost always want to load translations for the active locale along with our model. We can also used unscoped to bypass our default scope when we need to. So I don’t have a problem using a default scope here. You may feel differently, and that’s cool too.
With our default scopes in place, we solve the n + 1 problem. Now when we load a page with translated models, our server log is quite a bit tidier.
Parameters: {"locale"=>"fr"} Song Load (0.3ms) SELECT "songs".* FROM "songs" WHERE "songs"."album_id" IN (?, ?) ORDER BY order_in_album [[nil, 1], [nil, 2]] SongTranslation Load (0.4ms) SELECT "song_translations".* FROM "song_translations" WHERE "song_translations"."locale" = ? AND "song_translations"."song_id" IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) [["locale", "fr"], [nil, 1], [nil, 13], [nil, 2], [nil, 14], [nil, 3], [nil, 15], [nil, 4], [nil, 16], [nil, 5], [nil, 17], [nil, 6], [nil, 18], [nil, 7], [nil, 19], [nil, 8], [nil, 20], [nil, 9], [nil, 21], [nil, 10], [nil, 22], [nil, 11], [nil, 23], [nil, 12], [nil, 24], [nil, 25], [nil, 26], [nil, 27], [nil, 28], [nil, 29]]
Only one query is made to load our translations. Yay!
✋🏽 Heads up » If you have missing translations which force a fallback to the default translation, the n + 1 problem will arise again. However, as an extra query is made only for each translation that falls back to the default locale, the performance hit should be much more acceptable. Generally speaking we translate all our content, so the few extra queries for fallback should be ok. You should be aware of this issue, however, and always profile your app and check your SQL queries.
Overriding Translated Attributes
Much like our simple on-model solution, we can override a translated attribute if we want to mutate it in our model. For example, we can truncate our song titles to five characters with the following code.
class Song < ApplicationRecord include Translatable belongs_to :album translates :title default_scope { includes :current_translation } def title translation_for(:title)[0..4] end end
Remember the translation_for
method that our translatable concern defines? It comes in quite handy here. Now when we output our titles, they’re translated to the active locale and truncated to a maximum of five characters.
# In our views, if locale=:en <%= song.title %> # => "Trium" # If locale=:fr <%= song.title %> # => "Victo"
🔗 Resource » Get the complete code for the scalable separate translation model solution from GitHub.
Pros & Cons of Separate Translation Models
A discussion of the pros & cons of the translated separate models solution is basically the inverse of its on-model equivalent. The biggest gain that separate translated models give us is elegant scalability. If our app needs to support many locales, our database would stay clean and manageable, since each locale per model would have its own database record.
Of course, performance and complexity are the downsides of the separate model solution. Because we have a one-to-many relationship of model to translations, we constantly need to be alert to the n + 1 problem. Additionally, querying translated values often means we have to join our base models with our translations, resulting in more complex queries.
Depending on the needs of your app, you may prefer simple on-model localization or scalable separate translation models. I would start with on-model localization for an app that supports two locales and would consider separate translation models when a third locale is added.
🔗 Resource » If you want a gem that does a lot of the work of separate translation model localization for you, and gives you a lot of functionality we haven’t covered here, consider Globalize.
That’s about it for this one. Here’s our app in English and French after we localize its models.
Our app with English content
Our app with French content
Further Reading
If you want to go deeper into Rails i18n, check out our little library of tutorials:
- Setting and managing locales in Rails i18n
- Rails internationalization (I18n): seven best practices you should know about
- Lessons learned: naming and managing Rails i18n keys
- Localizing JavaScript in Rails apps
- Localize Your SLIM Templates In A Second With Slimkeyfy
And specifically on the topic of Active Record models, make sure you stop by these guides:
- Rails Validation: Pitfalls in Validating Uniqueness with Active Record
- Active Record: How to Speed Up Your SQL Queries
Et voilà !
We hope you’ve enjoyed this guide to localizing Rails Active Record models. Let us know if you want us to cover any other i18n topics. We’re always happy to hear from you.
And for a professional, feature-complete localization platform, check out Phrase. With a flexible API and CLI, branching and versioning, machine learning translation, and a powerful web console for your translators, Phrase does the heavy lifting for your i18n work, letting you focus on the creative code you love. Check out all of Phrase's products, and sign up for a free 14-day trial.