Software localization

Laravel I18n Frontend Best Practices

Learn Laravel i18n best practices to make your apps ready for simple localization, while keeping your code clean and fun to work with.
Software localization blog category featured image | Phrase

While Laravel is great for many kinds of applications, it is a general purpose framework. This means that we may need to build our own layers on top of Laravel to best suit our custom web apps. For internationalization (i18n) it provides some help out of the box. However, we'd like to provide you with some additional Laravel i18n tips and tricks.

The Laravel PHP framework needs no introduction. It has soared to the top of the PHP MVC pack, and for good reason: Laravel is one of the best-designed frameworks out there. It's simple, layered, and well documented. Laravel is just a joy to use.

I will assume that you know the basics of Laravel and have read its documentation on localization. Given that, we can jump into some insights that will help you with your localized Laravel app.

At time of writing, I'm using PHP 7.3, Laravel 5.7, and MySQL 5.7.

Laying the Ground: A Simple Locale Library

Laravel gives us some locale configuration in config/app.php and its app()->getLocale() and app()->setLocale() methods. These are fine for simple apps, but the moment our app grows past a few pages, we can find ourselves repeating code if we don't put some basic internationalization architecture into place.

Extend the Basic Locale Configuration

Out of the box, Laravel is locale-aware, giving us the app.locale and app.fallback_locale configuration options. We can add an app.supported_locales option to give us a single source of truth for the locales our app handles.

Our config/app.php would then look like this (excerpt).

'locale' => 'en',

'fallback_locale' => 'en',

/*

|--------------------------------------------------------------------------

| Supported Application Locales

|--------------------------------------------------------------------------

|

| Our supported locales will have available frontend and model

| translations.

|

*/

'supported_locales' => [

    'en' => [

        'name' => 'English',

        'dir'  => 'ltr',

    ],

    'ar' => [

        'name' => 'Arabic',

        'dir'  => 'rtl'

    ],

],

This setup will pair with a Locale class that we'll build and contains everything we need to display a locale on the frontend: Its language code, its name in the default locale, and its directionality to account for right-to-left languages.

Create A Locale Class

Now we can write a more robust, sexy localization API using this configuration. A basic Locale class can get us started.

In app/Locale.php.

<?php

namespace App;

class Locale

{

    /**

     * Cached copy of the configured supported locales

     *

     * @var string

     */

    protected static $configuredSupportedLocales = [];

    /**

     * Our instance of the Laravel app

     *

     * @var Illuminate\Foundation\Application

     */

    protected $app = '';

    /**

     * Make a new Locale instance

     *

     * @param Illuminate\Foundation\Application $app

     */

    public function __construct($app)

    {

        $this->app = $app;

    }

    /**

     * Retrieve the currently set locale

     *

     * @return string

     */

    public function current()

    {

        return $this->app->getLocale();

    }

    /**

     * Retrieve the configured fallback locale

     *

     * @return string

     */

    public function fallback()

    {

        return $this->app->make('config')['app.fallback_locale'];

    }

    /**

     * Set the current locale

     *

     * @param string $locale

     */

    public function set($locale)

    {

        $this->app->setLocale($locale);

    }

    /**

     * Retrieve the current locale's directionality

     *

     * @return string

     */

    public function dir()

    {

        return $this->getConfiguredSupportedLocales()[$this->current()]['dir'];

    }

    /**

     * Retrieve the name of the current locale in the app's

     * default language

     *

     * @return string

     */

    public function nameFor($locale)

    {

        return $this->getConfiguredSupportedLocales()[$locale]['name'];

    }

    /**

     * Retrieve all of our app's supported locale language keys

     *

     * @return array

     */

    public function supported()

    {

        return array_keys($this->getConfiguredSupportedLocales());

    }

    /**

     * Determine whether a locale is supported by our app

     * or not

     *

     * @return boolean

     */

    public function isSupported($locale)

    {

        return in_array($locale, $this->supported());

    }

    /**

     * Retrieve our app's supported locale's from configuration

     *

     * @return array

     */

    protected function getConfiguredSupportedLocales()

    {

        // cache the array for future calls

        if (empty(static::$configuredSupportedLocales)) {

            static::$configuredSupportedLocales = $this->app->make('config')['app.supported_locales'];

        }

        return static::$configuredSupportedLocales;

    }

}

Our Locale class uses an instance of the underlying Laravel app to expose a library of helper methods that make working with locales much more pleasant. It can also keep our code nice and DRY (Don't Repeat Yourself) by being our go-to API for dealing with locales.

Get a copy of the Locale class as a Gist.

Use Service Providers for Reusability and Clarity

Currently, we have to instantiate a new instance of the Locale class and inject our app instance every time we need one of its methods.

if ((new App\Locale(app())->isSupported('es')) {

    // Spanish is supported

}

This can be a pain, and we can reduce this pain a bit by using a Larvel Service Provider. These are just a way to register functionality with Laravel for reuse and inversion of control (IoC). They can make our code more testable and easier to reason about.

To create the provider, we can use the built-in Artisan command line tool.

php artisan make:provider LocaleServiceProvider

This will generate the boilerplate class at app/Providers/LocaleServiceProvider.php.

We can update this class' register method to bind our Locale class in Laravel's IoC container.

app/Providers/LocaleServiceProvider.php

<?php

namespace App\Providers;

use App\Locale;

use Illuminate\Support\ServiceProvider;

class LocaleServiceProvider extends ServiceProvider

{

    /**

     * Register services.

     *

     * @return void

     */

    public function register()

    {

        $this->app->singleton(Locale::class, function ($app) {

            return new Locale($app);

        });

    }

    //...

}

Get a copy of the LocaleServiceProvider class as a Gist.

We bind a singleton to our class name so that we don't build an instance multiple times during a request. We also create a single source of truth for the construction of the class by providing our app instance in one place.

Now we can register our new service provider.

In config/app.php (excerpt).

'providers' => [

    /*

     * Application Service Providers...

     */

    App\Providers\AppServiceProvider::class,

    App\Providers\AuthServiceProvider::class,

    // App\Providers\BroadcastServiceProvider::class,

    App\Providers\EventServiceProvider::class,

    App\Providers\RouteServiceProvider::class,

    App\Providers\LocaleServiceProvider::class,

],

Laravel will now bind our singleton to the class' name during every request. We can use this binding to access our class.

if(app(App\Locale::class)->isSupported('ar')) {

    // Arabic is supported

}

Use a Global Helper Function or a Facade for Clean Code

While this is better than writing (new App\Locale(app())->current()), manually calling the IoC container every time we want to work with our locale library is less than ideal. We can make our lives easier using by writing a global helper function.

First, we create app/helpers.php. In it, we can put this simple function.

/**

 * Retrieve our Locale instance

 *

 * @return App\Locale

 */

function locale()

{

    return app()->make(App\Locale::class);

}

Next, we register our file to be autoloaded by updating our Composer configuration.

composer.json (excerpt).

"autoload": {

        "classmap": [

            "database/seeds",

            "database/factories"

        ],

        "psr-4": {

            "App\\": "app/"

        },

        "files": ["app/helpers.php"]

    },

There should really only be a handful of global functions in our app, otherwise, we risk polluting the global namespace and colliding with framework or third-party code.

We can then run composer dump-autoload from the command line to make our new function available to our code.

And behold!

if (locale()->isSupported('ru')) {

    // Russian is supported

}

Much cleaner, right? Alternatively, we can use a facade to access our local library. This has the advantage of not polluting the global namespace. The choice of using a global function or a facade is largely a matter of personal pereference, however.

To use a facade, we write a little file at app/Facades/Loc.php.

<?php

namespace App\Facades;

use App\Locale;

use Illuminate\Support\Facades\Facade;

class Loc extends Facade

{

    /**

    * Get the registered name of the component.

    *

    * @return string

    */

    protected static function getFacadeAccessor() { return Locale::class; }

}

We then register the alias in config/app.php (excerpt).

'aliases' => [

        'App' => Illuminate\Support\Facades\App::class,

        'Artisan' => Illuminate\Support\Facades\Artisan::class,

        'Auth' => Illuminate\Support\Facades\Auth::class,

        // ...

        'URL' => Illuminate\Support\Facades\URL::class,

        'Validator' => Illuminate\Support\Facades\Validator::class,

        'View' => Illuminate\Support\Facades\View::class,

        'Loc' => App\Facades\Loc::class,

    ],

Now we can use our library via static method calls.

Loc::current();

if(Loc::isSupported('hi')) {

    // Hindi is supported

}

To avoid collision with the built-in PHP Locale class, we can name the class Loc.

Routing

With our neat library in place, we can get to the meat of our internationalization work. Our app needs a way to determine the current user's locale. We can do this via received headers, the user's IP address, or the currently requested URI.

Determine the Locale from the URI

We'll focus on the URI method here since it's quite common and the simplest to implement.

Assuming we have a music store app and we have routes like /artists/1/albums that we want to internationalize, we can set up the locale as a parameter in our routes.

In routes/web.php (excerpt).

Route::get('{locale}/artists', 'ArtistsController@index');

Route::get('{locale}/artists/{id}', 'ArtistsController@show');

Route::get('{locale}/artists/{id}/albums', 'ArtistAlbumsController@index');

You may notice that we are repeating ourselves quite a bit here, and this means that our controllers will have to handle our locale setting. An ArtistContoller should be dealing with artists, not locales.

Use Route Prefixes and Groups to Keep Things DRY

We can use route groups to eliminate this repetition.

In routes/web.php (excerpt).

Route::redirect('/', '/'.locale()->current(), 301);

Route::prefix('{locale}')->group(function () {

    Route::get('artists', 'ArtistsController@index');

    Route::get('artists/{id}', 'ArtistsController@show');

    Route::get('artists/{id}/albums', 'ArtistAlbumsController@index');

    //...

});

That looks a lot better, doesn't it? In order to make sure a locale is always set in our URI, notice that we redirect our root route to /en, given that English is our default locale.

Note » Some browsers will aggressively cache 301 redirects like the one we've defined above. So if a visitor hits the root / route and her URI resolves to /ar, she may continue to arrive at /ar even after we've updated our current locale in config/app.php. One way to resolve this would be to always include manual language switching for the site visitor.

Be Careful with Route Parameter Order

Continuing the example above, if you were to write the ArtistsController@index method like this...

public function index($id)

{

    // do something with $id -- broken ⚠

}

...you would find that the locale value is in the $id parameter. We can rewrite this method to get around this problem.

public function index($locale, $id)

{

    // do something with $id -- working ✅

}

Use Middleware to Separate Concerns and Document Behaviour

To actually set the locale, we need to tell our app about the language code we found in the URI. The cleanest way to do this is by using middleware.

We can create a new middleware class using Artisan on the command line.

php artisan make:middleware SetLocale

This will create a boilerplate at app/Http/Middleware/SetLocale.php. We can update the handle method of our new middleware to set the locale.

app/Http/Middleware/SetLocale.php

<?php

namespace App\Http\Middleware;

use Closure;

class SetLocale

{

    /**

     * Handle an incoming request.

     *

     * @param  \Illuminate\Http\Request  $request

     * @param  \Closure  $next

     * @return mixed

     */

    public function handle($request, Closure $next)

    {

        $desiredLocale = $request->segment(1);

        $locale = locale()->isSupported($desiredLocale) ? $desiredLocale : locale()->fallback();

        locale()->set($locale);

        return $next($request);

    }

}

We just grab the languagecode from /languagecode/foo, check if we support it, and fallback to a configured locale if we don't. In all cases, once this middleware handles the request, our locale will be set. First, though, we need to register it with Laravel.

In app/Http/Kernel.php (excerpt).

/**

 * The application's global HTTP middleware stack.

 *

 * These middleware are run during every request to your application.

 *

 * @var array

 */

protected $middleware = [

    \Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode::class,

    \App\Http\Middleware\SetLocale::class,

    \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,

    \App\Http\Middleware\TrimStrings::class,

    \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,

    \App\Http\Middleware\TrustProxies::class,

];

We set our middleware to run right after we check for maintenance mode. Of course, if your maintenance mode page needs to be localized, you may want to move the SetLocale middleware up the stack.

Now our locale will be set on each request of the application and we can retrieve it using locale()->current(). Moreover, a dedicated file and class exist to document this behavior so that future developers (including ourselves) can easily reason about how our app is determining its local. This is better than burying that code in the routing file.

The Frontend

Laravel documents its frontend localization capabilities pretty well. And with our little locale library, our Blade views can look squeaky clean.

A common resources/views/layouts/main.blade.php (excerpt).

<!DOCTYPE html>

<html lang="{{locale()->current()}}" dir="{{locale()->dir()}}">

<head>

    <meta charset="UTF-8">

    <meta name="viewport" content="width=device-width, initial-scale=1.0">

    <meta http-equiv="X-UA-Compatible" content="ie=edge">

    <title>@lang('layouts.main.site_title')</title>

</head>

<!-- ... -->

The above <html> tag will always have a supported locale and directionality ('ltr' or 'rtl'). The latter is important for right-to-left languages like Arabic and Hebrew since the whole page layout will change for those.

Use Translation Strings As Keys for Natural UI Code

The default translation files that Laravel uses can make our apps' views, and our internationalization/localization workflow, pretty clunky.

resources/lang/en/artists.php (excerpt).

'show' => [

    'header'    => ":name's Profile",

    'lead_copy' => 'The bio. The albums. The singles. All you need in one place.'

]

resources/views/artists/show.blade.php (excerpt).

<h1>@lang('artists.show.header', ['name' => $artist->name])</h1>

<p class="lead">@lang('artists.show.lead_copy')</p>

Instead, we can use the resources/lang/en.json JSON translation files to make our views look much more natural.

resources/views/artists/show.blade.php (excerpt).

<h1>@lang(":name's Profile", ['name' => $artist->name])</h1>

<p class="lead">@lang('The bio. The albums. The singles. All you need in one place.')</p>

While this method can be a bit verbose, it makes our views much easier to read. It also saves us from having to write translations for our default locale. In the example above, we wouldn't have an en.json file. If our app only supported Arabic and English, we could just have one translation file.

resources/lang/ar.json (excerpt).

{

    ":name's Profile": "إسم :name",

    "The bio. The albums. The singles. All you need in one place.": "القصة. الأغاني. الألابيم. كل ما تحتاج من معلومات في مكان واحد."

...

You can mix and match array-based PHP and JSON translation files.

In Closing

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 Phrase Localization Platform.

I hope these tips have helped you on your journey with Laravel i18n. This is a big topic, and we could dive into localizing other layers of a Laravel app, such as the model and data layers. I'm hoping we can do just that in an upcoming article. Stay tuned! :)