Laravel I18n Modelling Best Practices

Last time we covered Laravel I18n Frontend Best Practices. Here, we continue this best practice approach to I18n in Laravel, this time focusing on the data and model layers of a Laravel app. I won't assume you've read the previous article here, but it will help a bit if you have. I will assume you have the basics of Laravel down and that you're familiar with its Eloquent ORM. Let's get going.

Note: At time of writing, I'm using PHP 7.1, Laravel 5.5, and MySQL 5.6.

 

Laravel, created by Taylor Otwell, is currently one of the most popular PHP MVC frameworks. Otwell’s brainchild is immaculately designed, and gives us the scaffolding to write beautiful code. However, we need to put our own work in to build an internationalization architecture for our custom applications.

Start with Simple Attribute Suffixes

For many websites, we only need to handle two locales. Let’s assume we have a music store application that needs to be localized into Arabic and English. The schema for our Artist model can look like this.

database/migrations/2018_01_04_161459_create_artists_table.php (excerpt)

Notice that we have a name attribute that is localized into our two languages using locale suffixes.

Create a Localized Model Class

We will likely have multiple localized models in our application. To facilitate reuse and to make our lives easier as we develop our models, we can create a LocalizableModel superclass that handles our dynamic attributes via PHP’s magic __get() method. This method is called when we attempt to access a attribute that hasn’t been explicitly defined on an object.

app/I18n/LocalizableModel.php

Whenever a missing attribute, e.g. name, is called on a subclass of LocalizableModel, we check to see if this attribute has been designated as localizable. If it has, we dynamically retrieve the underlying attribute corresponding to the current locale.

Note: We’re using the Locale library we built in the previous article to determine the current locale from the request URI.

Our models can then derive from this class to get free localized attribute functionality.

app/Artist.php

Now, in our views, we can access the name attribute like so:

This attribute will be dynamic, of course, and its value will be the translated name in the current locale.

We can still access the underlying locale-specific attributes as we would any other Eloquent model attribute:

On JSON Serialization and Output

Laravel will output models as JSON automatically if we return them from our controllers. However, we need to let its Eloquent ORM know that we want to output the name attribute without suffixes. We can do this declaratively using Eloquent’s $hidden and $appends arrays. Let’s update our LocalizableModel class to do just that.

app/I18n/LocalizableModel.php

We override the Eloquent model constructor so that we hide and append attributes as we need to. We hide the suffixed (name_en) attributes and append the virtual (name) attributes to provide a sensible default structure for array and JSON output.

When we append a virtual name attribute to array and JSON output, Laravel will look for a getNameAttribute() method on our model. PHP’s __call() magic method can come in handy here. Much like __get() for attributes, __call() is called whenever a missing method is invoked on an object. So we use the __call() method to respond to that call when we get it, returning our name_en or name_ar attribute, depending on the current locale.

Now, when we return an Artist collection from one of our controllers—for example when we make an Artist::all() call—we get output that respects the current locale. So if the current locale is Arabic, our JSON might look like this:

If the current locale happens to be English, the JSON output will automatically reflect this by providing the English version of the name attribute.

Allow for Overrding Our Localized Model Output

Of course, we won’t always want our LocalizableModel’s default behaviour. Sometimes we may not want to provide our virtual attributes in array or JSON output. This is why we provided the $appendLocalizedAttributes flag in our superclass.

app/Artist.php (excerpt)

Similarly, we can expose the underlying suffixed attributes via the $hideLocaleSpecificAttributes attribute.

app/Artist.php (excerpt)

We can pair the above flags with Laravel’s $hidden and $append arrays to have complete flexibility over our localized attributes.

A Different Approach: Break Up the Models for Scale

For many applications, the attribute suffix solution will suffice. When our app supports more than a few locales, however, we need a more flexible internationalization architecture. Let’s refactor our Artist model so that it is broken up into a core model and a translations model. Their schemas are as follows.

database/migrations/2018_01_04_161459_create_artists_table.php (excerpt)

database/migrations/2018_01_07_170235_create_artist_translations_table.php (excerpt)

We’re using a translations one to many relationship, allowing for any number of translation models to be associated with a core application model. An Artist can have different ArtistTranslations for Arabic, English, French, Hebrew, and more.

We can add a simple ArtistTranslation model class so we can access artist_translations as a relationship of the Artist model.

app/Translations/ArtistTranslation.php

Updating our LocalizableModel Class

In order to accommodate our new internationalization architecture, we need to update our localized model superclass.

app/I18n/LocalizableModel.php (excerpt)

Beware of Performance and the n+1 Problem

Since we’re using a relationship that spans two database tables, we have to be careful about the n+1 problem. If we naively use our code like so…

…Laravel will query our database for each access to the name attribute. So, if we have 50 artists, we’ll make 51 SQL queries. This will bog our servers down very quickly.

This is why we eager load our translations by default in the LocalizableModel constructor using the Eloquent $with array. By doing this, Laravel will only make 2 queries when executing the previous code snippet: one query for the Artist model and one for its ArtistTranslations. Much more resource-efficient.

We also provide an $eagerLoadTranslations boolean flag, which subclasses can override to gain more granular control over translation loading, if need be.

The remaining code in our constructor maintains the same spirit as our suffixed solution. However, instead of hiding our suffixed attributes from array and JSON output by default, we now hide the translations relationship. Of course we still provide a $hideTranslations boolean for subclass control over that behaviour.

Speaking of our translations relationship, let’s look at the next.

app/I18n/LocalizableModel.php (excerpt)

One of the beautiful things about PHP is how easy it makes reflection. Our translations relationship maintains a naming convention: An Artist has many ArtistTranslation models, a Song has many SongTranslation models, etc.

Revisiting __get()

Since we’re now using a relationship instead of localized attributes, our __get() magic method will need to be updated to work with our new design.

app/I18n/LocalizableModel.php (excerpt)

We simply filter down the translations collection to the one corresponding to the current locale, and return the attribute in question. So if the current locale is French and the call is to $album->description, we retrieve album’s description en Français.

Note: We’re using the translations attribute not the translations() method above. This is important because using the attribute means Laravel will draw on already eager loaded translation models. If we use the translations() method instead, we may encounter the n+1 problem again.

Our __call() method remains the same before, as it effectively defers to the __get() method if no overrides are present. Here’s our full LocalizableModel class, then.

app/I18n/LocalizableModel.php

Our model classes can now subclass LocalizableModel and, again, get intuitive and DRY localization behaviour. Our Artist class, for example, can remain quite simple.

app/Artist.php

With this architecture in place, we can add as many locales to our application as we want. Each localized model will simply accept the new locale’s translation in its respective translations table, and our LocalizableModel superclass will provide access to its translated fields in a familiar fashion.

C’est Fini

Writing code to localize your app is one task, but working with translations is a completely different story. Many translations for multiple languages may quickly overwhelm you which will lead to the user’s confusion. Fortunately, PhraseApp can make your life as a developer easier! Feel free to learn more about PhraseApp, referring to the Getting Started guide.

I hope this has given you a good start on some of the best practices for internationalizing your Laravel app.  For applications with two languages or so, I recommend using the suffixed solution, since it is simpler to implement and maintain. However, at scale, when looking at a few or more languages for example, you may well find the two-model one to many solution more flexible for your app. For additional frontend best practices make sure to also check out our Laravel i18n frontend post.

Comments