Software localization

A Guide to Localizing Rails Active Record Models

Rails is still a strong choice for agile web development. Check out these two approaches to localizing Rails Active Record models.
Software localization blog category featured image | Phrase

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:

  1. Adding the localized attributes directly to each model e.g. title_en, title_fr.
  2. Adding a separate translation model for each base model e.g. Article and ArticleTranslation, 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.

Demo app in French | PhraseOur 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 for bin/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.

Demo app in English | Phrase

🔗 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.

English and French UI | PhraseRails 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 and review attributes from Album and db: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 record  where(locale: "fr").
  • default_translation retrieves the translation record associated with the model that corresponds to the configured default locale. Recall that we configured I18n.default_locale to be :en earlier. So in our case, default_translation will always retrieve the translation record where(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.

Demo app in English | Phrase

Our app with English content

Demo app in French | PhraseOur app with French content

Further Reading

If you want to go deeper into Rails i18n, check out our little library of tutorials:

And specifically on the topic of Active Record models, make sure you stop by these guides:

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.