A better way to set and manage locale data in your Phoenix Applications!

This article continues the "Phoenix I18n" series. Today you will learn how to craft a custom module plug that allows to set and persist the chosen locale. The final solution is going to take into consideration values from both GET param, cookie and HTTP header, thus being quite flexible.

In my previous article I’ve explained how to internationalize Phoenix applications with the help of Gettext. We have introduced support for two languages, covered the process of extracting translations, adding pluralizations and some other topics. Also, we have briefly talked about switching between locales by utilizing a third-party plug called set_locale. This plug is really convenient and easy to use but it appears that a similar solution can be coded from scratch quite easily. After all, it is much better to code some feature all by yourself to understand how exactly it works. Also, this way you have total control on how everything ties up together.

So, today I’d like to show you how to set and manage locale data in the Phoenix applications with the help of a module plug. Our solution is going to support three sources of locale data:

  • GET param
  • Cookie
  • HTTP header

This way once a user has chosen some locale setting, it will be persisted and utilized on subsequent visits without the need to adjust this setting again.

We will continue working on the demo application created in the previous article. If you’d like to follow along, simply clone this repo by running:

The final version of the application is available at the same repo, under the locale branch. All committed changes can be found on this page. Also note that in order to run the application you’ll require:

Some Cleanup

Before proceeding to the main part, let’s do some cleanup. As long as we are not going to employ the set_locale plug anymore, the following line can be removed from the mix.exs file:

Also, remove set_locale from the application (inside the same file):

Next, tweak the lib/demo_web/router.ex file by removing the third-party plug:

and keeping only the following scope:

This way we have got rid of the set_locale plug and may proceed to crafting our own solution.

Creating a Custom Locale Plug

So, we are going to create our own custom plug called simply Locale. Its behaviour will be somewhat similar to the set_locale plug used in the previous article, but with some differences. Here are the key points:

  • The locale should be initially set based on the value of the locale GET param. So, if I visit http://localhost:4000?locale=ru, Russian locale should be utilized.
  • If this GET param is not present, try to use the value from a cookie called locale.
  • If the cookie is not set as well, check the Accept-Language HTTP header.
  • Lastly, if the header is not present, fallback to a default locale. The same applies to scenarios when the requested locale is not supported.
  • As long as the default locale is already set in the config/config.exs (line config :demo, DemoWeb.Gettext, default_locale: "ru", locales: ~w(en ru)), there is no need to pass the default value to the plug again as it was done with the set_locale.
  • After the locale was successfully set, its value should be saved under the locale cookie.

All in all, nothing complex. Alright, start by hooking up a new plug by modifying the router.ex file:

Next, create a new lib/demo_web/plugs/locale_plug.ex file which is going to contain the actual plug:

So, this plug allows us to transform the Connection object somehow. As explained by the documentation, it should define two callbacks:

  • init/1 that initializes options to be passed to call/2. It may return simply nil though.
  • call/2 which performs the actual transformation. It accepts and must return the connection object.

Here is the first draft for these two callbacks:

init/1 does not need to initialize any options, so it simply returns nil. If, for example, you want it to accept a default locale, change it to something like:

The plug will then accept a default value in the route.ex file like this:

Now let’s talk about the call/2 callback. The part %Plug.Conn{params: %{"locale" => locale}} = conn allows us to fetch the locale param and assign it to the locale variable. _opts has the value of nil (because that’s what the init/1 callback returns) and we are not going to use it.

The problem is that the requested locale may not be supported at all, so we should check for such cases. This can be done inside the call function itself, or by using guard clauses:

when locale in @locales is our guard clause that checks whether the requested locale is present inside the @locales list (which will be defined in a moment). If it does present, the function will be executed, otherwise we proceed to the def call(conn, _opts), do: conn line and simply return the connection back without doing anything else.

Now all we need to do is define the @locales list:

Note that you cannot employ know_locales directly in the guard clause as you’ll end with an error:

Setting Locale

The next step to do is to actually set the locale by calling the put_locale/2 function that accepts a Gettext backend and the language’s code:

Also, don’t forget to return the conn itself:

Great! The first iteration is nearly finished and you may boot the server by running:

Navigate to the http://127.0.0.1:4000/ and make sure that the default locale (Russian, in my case) is used. Next try switching it by going to http://127.0.0.1:4000?locale=en — all text should be in English. Note that if you try to open http://127.0.0.1:4000/ again, the text will still be in English. If, however, you reboot the server, this setting will be lost and the default language will be utilized again. We’ll deal with this problem later.

UI Changes

Before we proceed to the next iteration, let’s also present two links to switch between locales for our own convenience. First of all, introduce a new helper inside the views/layout_view.ex file:

raw/1 function should be called here because otherwise the HTML will be rendered as plain text, whereas we want this string to turn into a hyperlink.

Next, simply utilize this helper inside the templates/layout/app.html.eex by modifying the default navigation block:

Great! Now you may switch between locales by simply clicking on one of these links.

Persisting Locale Data

Now that we have coded some very basic version of the plug, let’s try making it a bit more complex. What I want to do is store the chosen locale in a cookie named, quite unsuprisingly, locale:

The cookie is set using the put_resp_cookie/4 function. "locale" is the key, whereas locale is the value that should be stored under this key. Also, I’ve set the max_age option to 10 days, but you may provide a much greater value so that the cookie becomes virtually permanent. Note that you must assign the result of calling put_resp_cookie/4 to the conn, otherwise the data won’t be persisted.

Next, let’s make sure that the cookie actually has the correct data by printing out its contents inside the lib/demo_web/controllers/page_controller.ex:

Visit the http://127.0.0.1:4000/?locale=en URL and make sure that the console has the following output:

Brilliant!

Note that the same result may be achieved by storing locale inside the session, not cookie. To save some data inside the session, utilize the put_session/3 function:

:locale here is a key (which can also be represented as a string), whereas locale is a value. The data can be then read with the help of get_session/2 function:

Fetching Locale Data

The chosen locale is now persisted inside the cookie, but it needs to be properly read. On top of that, we have to make sure that the language is supported. Guard clause is not very suitable for this scenario because we need to perform too many actions. Instead, let’s stick with the case macro:

Here we are using two new functions that will be defined later: locale_from_params/1 and locale_from_cookies/1. These functions return either the locale itself or nil if the chosen locale is not supported or not provided. If nil was returned by both functions, call/2 simply returns conn and nothing else happens. Otherwise, we perform the same actions as before: set the locale and persist it inside the cookie.

Now let’s code the two new functions that will be marked as private:

Nothing fancy is going on here. We simply fetch params or cookies and then validate the value. validate_locale/1 is yet another private function:

This is where we are using our old guard clause that makes sure the locale is actually supported.

One thing I don’t like about the call/2 function is that we are persisting the locale under any circumstances, even if the same value is already stored. Let’s change this behaviour by utilizing a new function:

Here is the function itself:

Now if the cookie’s value does not match the newly chosen locale we overwrite it. Unfortunately, we cannot access conn.cookies in the guard clause, so I had to stick with the if macro instead.

Fetching From HTTP Header

At this point we are trying to fetch locale data from the GET param and from the cookie. Why don’t we also take the Accept-Locale HTTP header into consideration? To do that, we can utilize the functions already introduced in the set_locale plug:

These functions, basically, parse the HTTP header and make sure that the language is present in the list of allowed locales.

Now we may simply the locale_from_header/1 function:

And, this is it! You may now play with the application by switching between locales or trying to provide some non-supported language. Everything should work properly which is really cool.

Stick with PhraseApp!

Writing code to localize your application is one task, but working with translations is a something else. Having plenty of translations for multiple languages may quickly overwhelm you which will lead to the user’s confusion. Luckily, PhraseApp can make your life as a developer a lot easier!

Feel free to grab your 14-days trial now. PhraseApp supports all of your formats, including, of course, PO. It allows to easily import and export translation data and search for any missing translations, which is really convenient. On top of that, you can collaborate with translators as it is much better to have professionally done localization for your website. If you’d like to learn more about PhraseApp, refer to the Getting Started guide.

Conclusion

This is all for today! In this article we have seen how to set and manage locale data in Phoenix applications. You have seen how to create a module plug that tries to fetch locale from the GET param, cookie and HTTP header, which is quite flexible. The resulting functionality is somewhat similar to the set_locale plug, but now you have full control on how everything works and (hopefully!) understand the logic behind all this code.

I hope this tutorial was useful for you. As always, thanks for staying with me and see you in the next articles!


Also published on Medium.

A better way to set and manage locale data in your Phoenix Applications!
Rate this post
Comments