In our previous article, we’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 features all by yourself to understand how exactly it works. Also, this way you have total control over 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:
git clone git@github.com/phrase/PhraseAppPhoenixI18n
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:
- OTP (at least 18)
- Elixir (at least 1.4)
- Phoenix framework itself (1.3)
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:
defp deps do
[
# ...
{:set_locale, "~> 0.2.1"} # <===
]
end
Also, remove set_locale from the application (inside the same file):
def application do
[
mod: {Demo.Application, []},
extra_applications: [
:logger,
:runtime_tools,
:set_locale # <===
]
]
end
Next, tweak the lib/demo_web/router.ex file by removing the third-party plug:
plug SetLocale, gettext: DemoWeb.Gettext, default_locale: "ru"
and keeping only the following scope:
scope "/", DemoWeb do
pipe_through :browser # Use the default browser stack
get "/", PageController, :index
end
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
localeGET param. So, if I visithttp://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-LanguageHTTP 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(lineconfig :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
localecookie.
All in all, nothing complex. Alright, start by hooking up a new plug by modifying the router.ex file:
pipeline :browser do
# ...
plug DemoWeb.Plugs.Locale
end
Next, create a new lib/demo_web/plugs/locale_plug.ex file which is going to contain the actual plug:
defmodule DemoWeb.Plugs.Locale do import Plug.Conn end
So, this plug allows us to transform the Connection object somehow. As explained by the documentation, it should define two callbacks:
init/1that initializes options to be passed tocall/2. It may return simplynilthough.call/2which performs the actual transformation. It accepts and must return the connection object.
Here is the first draft for these two callbacks:
def init(_opts), do: nil
def call(%Plug.Conn{params: %{"locale" => locale}} = conn, _opts) do
end
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:
def init(default_locale), do: default_locale # here you may also want # to check if the default_locale actually supported by the app
The plug will then accept a default value in the route.ex file like this:
plug DemoWeb.Plugs.Locale, "en" # default locale set to "en"
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:
def call(%Plug.Conn{params: %{"locale" => locale}} = conn, _opts) when locale in @locales do
end
def call(conn, _opts), do: conn
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:
@locales Gettext.known_locales(DemoWeb.Gettext)
Note that you cannot employ know_locales directly in the guard clause as you’ll end with an error:
** (ArgumentError) invalid args for operator "in", it expects a compile-time list or compile-time range on the right side when used in guard expressions
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:
def call(%Plug.Conn{params: %{"locale" => locale}} = conn, _opts) when locale in @locales do
Gettext.put_locale(DemoWeb.Gettext, locale) # <===
end
Also, don’t forget to return the conn itself:
def call(%Plug.Conn{params: %{"locale" => locale}} = conn, _opts) when locale in @locales do
Gettext.put_locale(DemoWeb.Gettext, locale)
conn # <===
end
Great! The first iteration is nearly finished and you may boot the server by running:
mix phx.server
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:
def switch_locale_path(conn, locale, language) do
"<a href=\"#{page_path(conn, :index, locale: :en)}\">#{language}</a>" |> raw
end
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:
<header class="header">
<nav role="navigation">
<ul class="nav nav-pills pull-right">
<li><%= switch_locale_path @conn, :en, "English" %></li>
<li><%= switch_locale_path @conn, :ru, "Russian" %></li>
</ul>
</nav>
<span class="logo"></span>
</header>
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:
def call(%Plug.Conn{params: %{"locale" => locale}} = conn, _opts) when locale in @locales do
Gettext.put_locale(DemoWeb.Gettext, locale)
conn = put_resp_cookie conn, "locale", locale, max_age: 10*24*60*60 # <===
conn
end
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:
def index(conn, _params) do
conn.cookies["locale"] |> IO.inspect # <===
render conn, "index.html"
end
Visit the http://127.0.0.1:4000/?locale=en URL and make sure that the console has the following output:
[info] GET /
[debug] Processing with DemoWeb.PageController.index/2
Parameters: %{"locale" => "en"}
Pipelines: [:browser]
"en"
[info] Sent 200 in 0ms
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:
conn = conn |> put_session(:locale, locale)
: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:
get_session(conn, :locale)
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:
def call(conn, _opts) do
case locale_from_params(conn) || locale_from_cookies(conn) do
nil -> conn
locale ->
Gettext.put_locale(DemoWeb.Gettext, locale)
conn = put_resp_cookie conn, "locale", locale, max_age: 10 * 24 * 60 * 60
conn
end
end
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:
defp locale_from_params(conn) do
conn.params["locale"] |> validate_locale
end
defp locale_from_cookies(conn) do
conn.cookies["locale"] |> validate_locale
end
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:
defp validate_locale(locale) when locale in @locales, do: locale defp validate_locale(_locale), do: nil
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:
def call(conn, _opts) do
case locale_from_params(conn) || locale_from_cookies(conn) do
nil -> conn
locale ->
Gettext.put_locale(DemoWeb.Gettext, locale)
conn = conn |> persist_locale(locale) # <===
conn
end
end
Here is the function itself:
defp persist_locale(conn, new_locale) do
if conn.cookies["locale"] != new_locale do
conn |> put_resp_cookie("locale", new_locale, max_age: 10 * 24 * 60 * 60)
else
conn
end
end
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:
# Taken from set_locale plug written by Gerard de Brieder
# https://github.com/smeevil/set_locale/blob/fd35624e25d79d61e70742e42ade955e5ff857b8/lib/headers.ex
defp locale_from_header(conn) do
conn
|> extract_accept_language
|> Enum.find(nil, fn accepted_locale -> Enum.member?(@locales, accepted_locale) end)
end
def extract_accept_language(conn) do
case Plug.Conn.get_req_header(conn, "accept-language") do
[value | _] ->
value
|> String.split(",")
|> Enum.map(&parse_language_option/1)
|> Enum.sort(&(&1.quality > &2.quality))
|> Enum.map(&(&1.tag))
|> Enum.reject(&is_nil/1)
|> ensure_language_fallbacks()
_ ->
[]
end
end
defp parse_language_option(string) do
captures = Regex.named_captures(~r/^\s?(?<tag>[\w\-]+)(?:;q=(?<quality>[\d\.]+))?$/i, string)
quality = case Float.parse(captures["quality"] || "1.0") do
{val, _} -> val
_ -> 1.0
end
%{tag: captures["tag"], quality: quality}
end
defp ensure_language_fallbacks(tags) do
Enum.flat_map tags, fn tag ->
en = String.split(tag, "-")
if Enum.member?(tags, language), do: [tag], else: [tag, language]
end
end
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:
def call(conn, _opts) do
case locale_from_params(conn) || locale_from_cookies(conn) || locale_from_header(conn) do # <===
nil -> conn
locale ->
Gettext.put_locale(DemoWeb.Gettext, locale)
conn = conn |> persist_locale(locale)
conn
end
end
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 Phrase
Working with translation files can be challenging, especially when your app is of bigger scope and supports many languages. You might easily miss some translations for a specific language, which can lead to confusion among users.
And so Phrase can make your life easier: Grab your 14-day trial today. Phrase supports many different languages and frameworks, including JavaScript of course. It allows you to easily import and export translation data. What’s even greater, you can quickly understand which translation keys are missing because it’s easy to lose track when working with many languages in big applications.
On top of that, you can collaborate with translators as it’s much better to have professionally done localization for your website. If you’d like to learn more about Phrase, refer to the Phrase Localization Platform.
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!





