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.

Update » We’ve updated this article to use Laravel 5.7.

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.

Note »  At time of writing, I’m using PHP 7.3, Laravel 5.7, and MySQL 5.7.

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)

// ...

class CreateArtistsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('artists', function (Blueprint $table) {
            $table->increments('id');
            $table->timestamps();
            $table->string('name_en');
            $table->string('name_ar');
        });
    }

// ...

}

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

<?php

namespace App\I18n;

use Illuminate\Database\Eloquent\Model;

abstract class LocalizableModel extends Model {

    /**
     * Localized attributes.
     *
     * @var array
     */
    protected $localizable = [];


    /**
     * Magic method for retrieving a missing attribute.
     *
     * @param string $attribute
     * @return mixed
     */
    public function __get($attribute)
    {
        // We determine the current locale and return the associated
        // locale-specific attribute e.g. name_en
        if (in_array($attribute, $this->localizable)) {
            $localeSpecificAttribute = $attribute.'_'.locale()->current();

            return $this->{$localeSpecificAttribute};
        }

        return parent::__get($attribute);
    }
}

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

<?php

namespace App;

use App\I18n\LocalizableModel;

class Artist extends LocalizableModel
{
    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'name_en', 'name_ar',
    ];

    /**
     * Localized attributes.
     *
     * @var array
     */
    protected $localizable = [
        'name',
    ];

}

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

{{$artist->name}}

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:

{{$artist->name_en}}
{{$artist->name_ar}}

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

<?php

namespace App\I18n;

use Illuminate\Support\Str;
use Illuminate\Database\Eloquent\Model;

abstract class LocalizableModel extends Model {

    /**
     * Localized attributes.
     *
     * @var array
     */
    protected $localizable = [];


    /**
     * Whether or not to hide translated attributes e.g. name_en
     *
     * @var boolean
     */
    protected $hideLocaleSpecificAttributes = true;


    /**
     * Whether or not to append translatable attributes to array
     * output e.g. name
     *
     * @var boolean
     */
    protected $appendLocalizedAttributes = true;


    /**
     * Make a new translatable model
     *
     * @param array $attributes
     */
    public function __construct($attributes = [])
    {
        // We dynamically append localizable attributes to array output
        // and hide the localized attributes from array output
        foreach($this->localizable as $localizableAttribute) {
            if ($this->appendLocalizedAttributes) {
                $this->appends[] = $localizableAttribute;
            }

            if ($this->hideLocaleSpecificAttributes) {
                foreach(locale()->supported() as $locale) {
                    $this->hidden[] = $localizableAttribute.'_'.$locale;
                }
            }
        }

        parent::__construct($attributes);
    }


    /**
     * Magic method for retrieving a missing attribute.
     *
     * @param string $attribute
     * @return mixed
     */
    public function __get($attribute)
    {
        // We determine the current locale and return the associated
        // locale-specific attribute e.g. name_en
        if (in_array($attribute, $this->localizable)) {
            $localeSpecificAttribute = $attribute.'_'.locale()->current();

            return $this->{$localeSpecificAttribute};
        }

        return parent::__get($attribute);
    }


    /**
     * Magic method for calling a missing instance method.
     *
     * @param string $method
     * @param array $arguments
     * @return mixed
     */
    public function __call($method, $arguments)
    {
        // We handle the accessor calls for all our localizable attributes
        // e.g. getNameAttribute()
        foreach($this->localizable as $localizableAttribute) {
            if ($method === 'get'.Str::studly($localizableAttribute).'Attribute') {
                return $this->{$localizableAttribute};
            }
        }

        return parent::__call($method, $arguments);
    }

}

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:

[
    {
        "id": 1,
        "created_at": "2018-01-05 17:55:46",
        "updated_at": "2018-01-05 17:55:46",
        "name": "آناء جاسم الزامل"
    },
    {
        "id": 2,
        "created_at": "2018-01-05 17:55:46",
        "updated_at": "2018-01-05 17:55:46",
        "name": "نصار الصقيه"
    },
    {
        "id": 3,
        "created_at": "2018-01-05 17:55:46",
        "updated_at": "2018-01-05 17:55:46",
        "name": "عبد الرزاق جاسم إسلام القحطاني"
    },

    //...

]

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)

// ...

class Artist extends LocalizableModel
{
   // ...

    /**
     * Localized attributes.
     *
     * @var array
     */
    protected $localizable = [
        'name',
    ];

    // Do not include the virtual 'name' attribute in array
    // or JSON output
    protected $appendLocalizedAttributes = false;

}

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

app/Artist.php (excerpt)

<?php

//...

class Artist extends LocalizableModel
{
    //...

    // Expose our suffixed attributes in our model output
    protected $hideLocaleSpecificAttributes = false;
}

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)

// ...

class CreateArtistsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('artists', function (Blueprint $table) {
            $table->increments('id');
            $table->timestamps();
        });
    }

    // ...
}

database/migrations/2018_01_07_170235_create_artist_translations_table.php (excerpt)

// ...

class CreateArtistTranslationsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('artist_translations', function (Blueprint $table) {
            $table->increments('id');
            $table->timestamps();
            $table->integer('artist_id')->unsigned();
            $table->string('locale', 5)->index();
            $table->string('name');

            $table->foreign('artist_id')
                  ->references('id')->on('artists')
                  ->onDelete('cascade');
        });
    }

    // ...

}

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

<?php

namespace App\Translations;

use Illuminate\Database\Eloquent\Model;

class ArtistTranslation extends Model {}

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)

<?php

namespace App\I18n;

use Illuminate\Support\Str;
use Illuminate\Database\Eloquent\Model;

abstract class LocalizableModel extends Model {

    /**
     * Localized attributes
     *
     * @var array
     */
    protected $localizable = [];


    /**
     * Whether or not to eager load translations
     *
     * @var boolean
     */
    protected $eagerLoadTranslations = true;

    /**
     * Whether or not to hide translations
     *
     * @var boolean
     */
    protected $hideTranslations = true;


    /**
     * Whether or not to append translatable attributes to array output
     *
     * @var boolean
     */
    protected $appendLocalizedAttributes = true;


    /**
     * Make a new translatable model
     *
     * @param array $attributes
     */
    public function __construct($attributes = [])
    {
        if ($this->eagerLoadTranslations) {
            $this->with[] = 'translations';
        }

        if($this->hideTranslations) {
            $this->hidden[] = 'translations';
        }

        // We dynamically append localizable attributes to array output
        if ($this->appendLocalizedAttributes) {
            foreach($this->localizable as $localizableAttribute) {
                $this->appends[] = $localizableAttribute;
            }
        }

        parent::__construct($attributes);
    }

    // ...

}

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…

$artists = Artist::all();

foreach($artists as $artist) {
    echo $artist->name;
}

…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)

    /**
     * This model's translations
     *
     * @return Illuminate\Database\Eloquent\Relations\HasMany
     */
    public function translations()
    {
        $modelName = class_basename(get_class($this));

        return $this->hasMany("App\\Translations\\{$modelName}Translation");
    }

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)

    /**
     * Magic method for retrieving a missing attribute
     *
     * @param string $attribute
     * @return mixed
     */
    public function __get($attribute)
    {
        // If the attribute is localizable, we retrieve its translation
        // for the current locale
        foreach($this->localizable as $localizableAttribute) {
            if (in_array($attribute, $this->localizable)) {
                return $this->translations
                            ->where('locale', locale()->current())
                            ->first()
                            ->{$localizableAttribute};
            }
        }

        return parent::__get($attribute);
    }

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

<?php

namespace App\I18n;

use Illuminate\Support\Str;
use Illuminate\Database\Eloquent\Model;

abstract class LocalizableModel extends Model {

    /**
     * Localized attributes
     *
     * @var array
     */
    protected $localizable = [];


    /**
     * Whether or not to eager load translations
     *
     * @var boolean
     */
    protected $eagerLoadTranslations = true;

    /**
     * Whether or not to hide translations
     *
     * @var boolean
     */
    protected $hideTranslations = true;


    /**
     * Whether or not to append translatable attributes to array output
     *
     * @var boolean
     */
    protected $appendLocalizedAttributes = true;


    /**
     * Make a new translatable model
     *
     * @param array $attributes
     */
    public function __construct($attributes = [])
    {
        if ($this->eagerLoadTranslations) {
            $this->with[] = 'translations';
        }

        if($this->hideTranslations) {
            $this->hidden[] = 'translations';
        }

        // We dynamically append localizable attributes to array output
        if ($this->appendLocalizedAttributes) {
            foreach($this->localizable as $localizableAttribute) {
                $this->appends[] = $localizableAttribute;
            }
        }

        parent::__construct($attributes);
    }


    /**
     * This model's translations
     *
     * @return Illuminate\Database\Eloquent\Relations\HasMany
     */
    public function translations()
    {
        $modelName = class_basename(get_class($this));

        return $this->hasMany("App\\Translations\\{$modelName}Translation");
    }


    /**
     * Magic method for retrieving a missing attribute
     *
     * @param string $attribute
     * @return mixed
     */
    public function __get($attribute)
    {
        // If the attribute is localizable, we retrieve its translation
        // for the current locale
        foreach($this->localizable as $localizableAttribute) {
            if (in_array($attribute, $this->localizable)) {
                return $this->translations
                            ->where('locale', locale()->current())
                            ->first()
                            ->{$localizableAttribute};
            }
        }

        return parent::__get($attribute);
    }


    /**
     * Magic method for calling a missing instance method
     *
     * @param string $method
     * @param array $arguments
     * @return mixed
     */
    public function __call($method, $arguments)
    {
        foreach($this->localizable as $localizableAttribute) {
            // e.g. "getNameAttribute"
            if ($method === 'get'.Str::studly($localizableAttribute).'Attribute') {
                return $this->{$localizableAttribute};
            }
        }

        return parent::__call($method, $arguments);
    }

}

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

<?php

namespace App;

use App\I18n\LocalizableModel;

class Artist extends LocalizableModel
{
    /**
     * Localized attributes
     *
     * @var array
     */
    protected $localizable = ['name'];
}

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, Phrase can make your life as a developer easier! Feel free to learn more about Phrase, 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.

4.9 (98.18%) 11 votes
Comments