{"id":9658,"date":"2020-01-30T16:40:13","date_gmt":"2020-01-30T14:40:13","guid":{"rendered":"https:\/\/phrase.com\/blog\/?p=9658"},"modified":"2024-11-04T11:51:10","modified_gmt":"2024-11-04T10:51:10","slug":"detecting-a-users-locale","status":"publish","type":"post","link":"https:\/\/phrase.com\/blog\/posts\/detecting-a-users-locale\/","title":{"rendered":"Detecting a User&#8217;s Locale in a Web App"},"content":{"rendered":"<p>Whether we&#8217;re developing a simple blog, or a sophisticated, modern single-page application (SPA), oftentimes, when considering <a href=\"https:\/\/phrase.com\/blog\/posts\/i18n-a-simple-definition\/\">i18n<\/a> in a web application, we hit an important question: how do we detect a user&#8217;s language preference? This is important because we always want to provide the best user experience, and if the user has defined a set of preferred languages in his or her browser, we want to do our best to present our content in those preferred languages.<br \/>\nIn this article, we&#8217;ll go through three different ways of detecting a user&#8217;s locale: through the browser&#8217;s <code>navigator.language<\/code>s (on the client) object, through the <code>Accept-Language<\/code> HTTP header (on the server), and through geolocation using the user&#8217;s IP address (on the server).<\/p>\n<div id=\"ez-toc-container\" class=\"ez-toc-v2_0_69_1 counter-hierarchy ez-toc-counter ez-toc-grey ez-toc-container-direction\">\n<div class=\"ez-toc-title-container\">\n<p class=\"ez-toc-title\" style=\"cursor:inherit\">Overview<\/p>\n<span class=\"ez-toc-title-toggle\"><a href=\"#\" class=\"ez-toc-pull-right ez-toc-btn ez-toc-btn-xs ez-toc-btn-default ez-toc-toggle\" aria-label=\"Toggle Table of Content\"><span class=\"ez-toc-js-icon-con\"><span class=\"\"><span class=\"eztoc-hide\" style=\"display:none;\">Toggle<\/span><span class=\"ez-toc-icon-toggle-span\"><svg style=\"fill: #999;color:#999\" xmlns=\"http:\/\/www.w3.org\/2000\/svg\" class=\"list-377408\" width=\"20px\" height=\"20px\" viewBox=\"0 0 24 24\" fill=\"none\"><path d=\"M6 6H4v2h2V6zm14 0H8v2h12V6zM4 11h2v2H4v-2zm16 0H8v2h12v-2zM4 16h2v2H4v-2zm16 0H8v2h12v-2z\" fill=\"currentColor\"><\/path><\/svg><svg style=\"fill: #999;color:#999\" class=\"arrow-unsorted-368013\" xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"10px\" height=\"10px\" viewBox=\"0 0 24 24\" version=\"1.2\" baseProfile=\"tiny\"><path d=\"M18.2 9.3l-6.2-6.3-6.2 6.3c-.2.2-.3.4-.3.7s.1.5.3.7c.2.2.4.3.7.3h11c.3 0 .5-.1.7-.3.2-.2.3-.5.3-.7s-.1-.5-.3-.7zM5.8 14.7l6.2 6.3 6.2-6.3c.2-.2.3-.5.3-.7s-.1-.5-.3-.7c-.2-.2-.4-.3-.7-.3h-11c-.3 0-.5.1-.7.3-.2.2-.3.5-.3.7s.1.5.3.7z\"\/><\/svg><\/span><\/span><\/span><\/a><\/span><\/div>\n<nav><ul class='ez-toc-list ez-toc-list-level-1 ' ><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-1\" href=\"https:\/\/phrase.com\/blog\/posts\/detecting-a-users-locale\/#client-side-the-navigatorlanguages-object\" title=\"Client-side: The navigator.languages Object\">Client-side: The navigator.languages Object<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-2\" href=\"https:\/\/phrase.com\/blog\/posts\/detecting-a-users-locale\/#server-side-the-accept-language-http-header\" title=\"Server-Side: The Accept-Language HTTP Header\">Server-Side: The Accept-Language HTTP Header<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-3\" href=\"https:\/\/phrase.com\/blog\/posts\/detecting-a-users-locale\/#server-side-geolocation-by-ip-address\" title=\"Server-side: Geolocation by IP Address\">Server-side: Geolocation by IP Address<\/a><ul class='ez-toc-list-level-3' ><li class='ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-4\" href=\"https:\/\/phrase.com\/blog\/posts\/detecting-a-users-locale\/#using-maxmind-for-geolocation\" title=\"Using MaxMind for Geolocation\">Using MaxMind for Geolocation<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-5\" href=\"https:\/\/phrase.com\/blog\/posts\/detecting-a-users-locale\/#peter-kahls-country-to-locale-package\" title=\"Peter Kahl&#8217;s Country-to-Locale Package\">Peter Kahl&#8217;s Country-to-Locale Package<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-6\" href=\"https:\/\/phrase.com\/blog\/posts\/detecting-a-users-locale\/#the-ip-address-locale-detector-class\" title=\"The IP Address Locale Detector Class\">The IP Address Locale Detector Class<\/a><\/li><\/ul><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-7\" href=\"https:\/\/phrase.com\/blog\/posts\/detecting-a-users-locale\/#server-side-cascading-locale-detection\" title=\"Server-side: Cascading Locale Detection\">Server-side: Cascading Locale Detection<\/a><\/li><\/ul><\/nav><\/div>\n<h2><span class=\"ez-toc-section\" id=\"client-side-the-navigatorlanguages-object\"><\/span>Client-side: The <code>navigator.language<\/code>s Object<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>Modern browsers provide a <code>navigator.language<\/code>s object that we can use to get all the preferred languages the user has set in his or her browser.<br \/>\n<img loading=\"lazy\" decoding=\"async\" class=\"aligncenter wp-image-9669 size-large\" src=\"https:\/\/phrase.com\/wp-content\/uploads\/2020\/01\/detectinguserlocale202001-firefox-browser-settings-1024x531.png\" alt=\"Browser navigator.languages object for webpage language settings | Phrase\" width=\"1024\" height=\"531\" srcset=\"https:\/\/phrase.com\/wp-content\/uploads\/2020\/01\/detectinguserlocale202001-firefox-browser-settings-1024x531.png 1024w, https:\/\/phrase.com\/wp-content\/uploads\/2020\/01\/detectinguserlocale202001-firefox-browser-settings-300x155.png 300w, https:\/\/phrase.com\/wp-content\/uploads\/2020\/01\/detectinguserlocale202001-firefox-browser-settings-768x398.png 768w, https:\/\/phrase.com\/wp-content\/uploads\/2020\/01\/detectinguserlocale202001-firefox-browser-settings.png 1320w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" \/><\/p>\n<p style=\"text-align: center;\"><em>The language settings in Firefox<\/em><\/p>\n<p>Given the settings above, if we were to open the Firefox console and check the value of <code>navigator.languages<\/code>, we would get the following:<br \/>\n<img loading=\"lazy\" decoding=\"async\" class=\"wp-image-9671 size-full aligncenter\" src=\"https:\/\/phrase.com\/wp-content\/uploads\/2020\/01\/detectinguserlocale202001-navigator-languages.png\" alt=\"Firefox navigator.languages object value | Phrase\" width=\"440\" height=\"80\" srcset=\"https:\/\/phrase.com\/wp-content\/uploads\/2020\/01\/detectinguserlocale202001-navigator-languages.png 440w, https:\/\/phrase.com\/wp-content\/uploads\/2020\/01\/detectinguserlocale202001-navigator-languages-300x55.png 300w\" sizes=\"(max-width: 440px) 100vw, 440px\" \/><\/p>\n<p style=\"text-align: center;\"><em>The codes for the locales match the ones in our browser settings<\/em><\/p>\n<p><code>navigator.languages<\/code> is <a href=\"https:\/\/caniuse.com\/#search=navigator%20languages\">available in all modern web browsers<\/a> and is generally safe to rely on. So let&#8217;s write a reusable JavaScript function that tell us the preferred language(s) of the current user.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"js\">function getBrowserLocales(options = {}) {\n  const defaultOptions = {\n    languageCodeOnly: false,\n  };\n  const opt = {\n    ...defaultOptions,\n    ...options,\n  };\n  const browserLocales =\n    navigator.languages === undefined\n      ? [navigator.language]\n      : navigator.languages;\n  if (!browserLocales) {\n    return undefined;\n  }\n  return browserLocales.map(locale =&gt; {\n    const trimmedLocale = locale.trim();\n    return opt.languageCodeOnly\n      ? trimmedLocale.split(\/-|_\/)[0]\n      : trimmedLocale;\n  });\n}<\/pre>\n<p><code>getBrowserLocales()<\/code> checks the <code>navigator.languages<\/code> array, falling back on <code>navigator.language<\/code> if the array isn&#8217;t available. It&#8217;s worth noting that in some browsers, like Chrome, <code>navigator.language<\/code> will be the <em>UI<\/em> language, which is likely the language the <em>operating system<\/em> is set to. This is different than <code>navigator.languages<\/code>, which has the user-set preferred languages in the <em>browser<\/em> itself.<\/p>\n<p>\u270b\ud83c\udffd <em>Heads up \u00bb<\/em> If you&#8217;re supporting Internet Explorer, you will need to use the <code>navigator.userLanguage<\/code> and <code>navigaor.browserLanguage<\/code> properties. Of course, you will also need to replace all instances of <code>const<\/code> with <code>var<\/code> in the code above.<\/p>\n<p>Our function also has a convenient <code>languageCodeOnly<\/code> option, which will trim off the country codes of locales before it returns them. This can be handy when our app isn&#8217;t really handling the regional nuances of a language, e.g. we just have one version of English content.<br \/>\n<img loading=\"lazy\" decoding=\"async\" class=\"aligncenter wp-image-9672 size-full\" src=\"https:\/\/phrase.com\/wp-content\/uploads\/2020\/01\/detectinguserlocale202001-language-code-only.png\" alt=\"With languageCodeOnly: true, we get the languages without countries | Phrase\" width=\"658\" height=\"158\" srcset=\"https:\/\/phrase.com\/wp-content\/uploads\/2020\/01\/detectinguserlocale202001-language-code-only.png 658w, https:\/\/phrase.com\/wp-content\/uploads\/2020\/01\/detectinguserlocale202001-language-code-only-300x72.png 300w\" sizes=\"(max-width: 658px) 100vw, 658px\" \/><\/p>\n<p style=\"text-align: center;\"><em>With languageCodeOnly: true, we get the languages without countries<\/em><\/p>\n<h2><span class=\"ez-toc-section\" id=\"server-side-the-accept-language-http-header\"><\/span>Server-Side: The <code>Accept-Language<\/code> HTTP Header<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>If the user sets his or her language preferences in a modern browser, the browser will, in turn, send an HTTP header that relays these language preferences to the server with each request. This is the <code>Accept-Language<\/code> header, and it often looks something like this: <code>Accept-Language: en-CA,ar-EG;q=0.5<\/code>.<br \/>\nThe header lists the user&#8217;s preferred languages, with a weight defined by a <code>q<\/code> value, given to each. When an explicit <code>q<\/code> value is not specified, a default of <code>1.0<\/code> is assumed. So, in the above header value, the client is indicating that the user prefers Canadian English (with a weight of <code>q = 1.0<\/code>), then Egyptian Arabic (with a weight of <code>q = 0.5<\/code>).<br \/>\nWe can use this standard HTTP header to determine the user&#8217;s preferred locales. Let&#8217;s write a class called <code>HttpAcceptLanguageHeaderLocaleDetector<\/code> to do this. We&#8217;ll use PHP here, but you can use any language you like; the <code>Accept-Language<\/code> header should be the same (or similar enough) in all environments.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"php\">&lt;?php\nclass HttpAcceptLanguageHeaderLocaleDetector\n{\n  const HTTP_ACCEPT_LANGUAGE_HEADER_KEY = 'HTTP_ACCEPT_LANGUAGE';\n  public static function detect()\n  {\n    $httpAcceptLanguageHeader = static::getHttpAcceptLanguageHeader();\n    if ($httpAcceptLanguageHeader == null) {\n      return [];\n    }\n    $locales = static::getWeightedLocales($httpAcceptLanguageHeader);\n    $sortedLocales = static::sortLocalesByWeight($locales);\n    return array_map(function ($weightedLocale) {\n      return $weightedLocale['locale'];\n    }, $sortedLocales);\n  }\n  private static function getHttpAcceptLanguageHeader()\n  {\n    if (isset($_SERVER[static::HTTP_ACCEPT_LANGUAGE_HEADER_KEY])) {\n      return trim($_SERVER['HTTP_ACCEPT_LANGUAGE']);\n    } else {\n      return null;\n    }\n  }\n  private static function getWeightedLocales($httpAcceptLanguageHeader)\n  {\n    if (strlen($httpAcceptLanguageHeader) == 0) {\n      return [];\n    }\n    $weightedLocales = [];\n    \/\/ We break up the string 'en-CA,ar-EG;q=0.5' along the commas,\n    \/\/ and iterate over the resulting array of individual locales. Once\n    \/\/ we're done, $weightedLocales should look like\n    \/\/ [['locale' =&gt; 'en-CA', 'q' =&gt; 1.0], ['locale' =&gt; 'ar-EG', 'q' =&gt; 0.5]]\n    foreach (explode(',', $httpAcceptLanguageHeader) as $locale) {\n      \/\/ separate the locale key (\"ar-EG\") from its weight (\"q=0.5\")\n      $localeParts = explode(';', $locale);\n      $weightedLocale = ['locale' =&gt; $localeParts[0]];\n      if (count($localeParts) == 2) {\n        \/\/ explicit weight e.g. 'q=0.5'\n        $weightParts = explode('=', $localeParts[1]);\n        \/\/ grab the '0.5' bit and parse it to a float\n        $weightedLocale['q'] = floatval($weightParts[1]);\n      } else {\n        \/\/ no weight given in string, ie. implicit weight of 'q=1.0'\n        $weightedLocale['q'] = 1.0;\n      }\n      $weightedLocales[] = $weightedLocale;\n    }\n    return $weightedLocales;\n  }\n  \/**\n   * Sort by high to low `q` value\n   *\/\n  private static function sortLocalesByWeight($locales)\n  {\n    usort($locales, function ($a, $b) {\n      \/\/ usort will cast float values that we return here into integers,\n      \/\/ which can mess up our sorting. So instead of subtracting the `q`,\n      \/\/ values and returning the difference, we compare the `q` values and\n      \/\/ explicitly return integer values.\n      if ($a['q'] == $b['q']) {\n        return 0;\n      }\n      if ($a['q'] &gt; $b['q']) {\n        return -1;\n      }\n      return 1;\n    });\n    return $locales;\n  }\n}<\/pre>\n<p>This long bit of code is actually not very complicated. In the only public method, <code>detect()<\/code>, our class does the following:<\/p>\n<ol>\n<li>Gets the raw string value of the <code>Accept-Language<\/code> header, e.g. <code>\"en-CA,ar-EG;q=0.5\"<\/code><\/li>\n<li>Uses the helper method <code>getWeightedLocales()<\/code> to parse the header string into an array that looks like <code>[['locale' =&gt; 'en-CA', 'q' =&gt; 1.0], ['locale' =&gt; 'ar-EG', 'q' =&gt; 0.5]]<\/code>.<\/li>\n<li>Uses the helper method <code>sortLocalesByWeight()<\/code> to sort the above array from highest to lowest <code>q<\/code> value.<\/li>\n<li>Plucks the <code>locale<\/code> values from the sorted array, returning an array that looks like <code>['en-CA', 'ar-EG']<\/code>.<\/li>\n<\/ol>\n<p>We can now use our new class to get a nice, consumable array of locale codes based on the <code>Accept-Language<\/code> HTTP header.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"php\">&lt;?php\n$locales = HttpAcceptLanguageHeaderLocaleDetector::detect();\n\/\/ =&gt; ['en-CA', 'ar-EG']<\/pre>\n<h2><span class=\"ez-toc-section\" id=\"server-side-geolocation-by-ip-address\"><\/span>Server-side: Geolocation by IP Address<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>Sometimes the <code>Accept-Language<\/code> header won&#8217;t be present in the requests to our server. In these cases we might want to use the user&#8217;s IP address to determine the user&#8217;s country, and infer the locale or language from this country.<\/p>\n<p>\u270b\ud83c\udffd <em>Heads up \u00bb<\/em> Geolocation should be used as a last resort when detecting the user&#8217;s locale, as it can often lead to an incorrect locale determination. For example, if we see that our user is coming from Canada, do we assume that his or her preferred language is English or French? Both are formal and widely-used languages in the country. And, of course, the user could belong to an Arabic-speaking minority, or be a Spanish-speaking visitor.<\/p>\n<h3><span class=\"ez-toc-section\" id=\"using-maxmind-for-geolocation\"><\/span>Using MaxMind for Geolocation<span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>In order to determine the user&#8217;s country by the request&#8217;s IP address, we&#8217;ll use the MaxMind PHP API and the MaxMind geolocation database. MaxMind is a company that offers a few IP-related products, and among them are two that are of interest to us here:<\/p>\n<ul>\n<li><a href=\"https:\/\/www.maxmind.com\/en\/geoip-databases\">The GeoIP2 Databases<\/a> \u2014 these are MaxMind&#8217;s commercial geolocation databases and are low-latency and subscription-based. You may want to upgrade to these if you want more up-to-date or faster databases.<\/li>\n<li><a href=\"https:\/\/dev.maxmind.com\/geoip\/\">The GeoLite2 Databases<\/a> \u2014\u00a0these are MaxMind&#8217;s free geolocation databases, and while reportedly less accurate than their commercial counterparts, they&#8217;re more than enough to get started with. We&#8217;ll be using a GeoLite2 database here. Do note that you will need to credit Maxmind on your public web page and link back to their site if you use one of their free databases.<\/li>\n<\/ul>\n<p>To install the database, just <a href=\"https:\/\/www.maxmind.com\/en\/geolite2\/signup\">sign up<\/a> for a free MaxMind account. You&#8217;ll receive an email with a sign-in link. Follow the link and sign in. Once you do, you should land on your <em>Account Summary<\/em> page.<br \/>\n<img loading=\"lazy\" decoding=\"async\" class=\"aligncenter wp-image-9673 size-large\" src=\"https:\/\/phrase.com\/wp-content\/uploads\/2020\/01\/detectinguserlocale202001-maxmind-download-link-1024x193.png\" alt=\"MaxMind Download Databases | Phrase\" width=\"1024\" height=\"193\" srcset=\"https:\/\/phrase.com\/wp-content\/uploads\/2020\/01\/detectinguserlocale202001-maxmind-download-link-1024x193.png 1024w, https:\/\/phrase.com\/wp-content\/uploads\/2020\/01\/detectinguserlocale202001-maxmind-download-link-300x57.png 300w, https:\/\/phrase.com\/wp-content\/uploads\/2020\/01\/detectinguserlocale202001-maxmind-download-link-768x145.png 768w, https:\/\/phrase.com\/wp-content\/uploads\/2020\/01\/detectinguserlocale202001-maxmind-download-link.png 1474w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" \/><\/p>\n<p style=\"text-align: center;\"><em>Click the Download Databases link on the Account Summary page<\/em><\/p>\n<p>This will take you to a page with the list of free GeoList2 databases. Grab the <em>country binary<\/em> database from there.<br \/>\n<img loading=\"lazy\" decoding=\"async\" class=\"aligncenter wp-image-9676 size-full\" src=\"https:\/\/phrase.com\/wp-content\/uploads\/2020\/01\/detectinguserlocale202001-maxmind-databases-1.png\" alt=\"MaxMind country binary database | Phrase\" width=\"729\" height=\"752\" srcset=\"https:\/\/phrase.com\/wp-content\/uploads\/2020\/01\/detectinguserlocale202001-maxmind-databases-1.png 729w, https:\/\/phrase.com\/wp-content\/uploads\/2020\/01\/detectinguserlocale202001-maxmind-databases-1-291x300.png 291w\" sizes=\"(max-width: 729px) 100vw, 729px\" \/><\/p>\n<p style=\"text-align: center;\"><em>We want the country binary database for our purposes<\/em><\/p>\n<p>Place the file you downloaded somewhere in your project.<br \/>\nWe&#8217;ll also need the MaxMind PHP API to work with the database. We can install that with <a href=\"https:\/\/getcomposer.org\/\">Composer<\/a>.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"js\">composer require geoip2\/geoip2:~2.0<\/pre>\n<h3><span class=\"ez-toc-section\" id=\"peter-kahls-country-to-locale-package\"><\/span>Peter Kahl&#8217;s Country-to-Locale Package<span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>We&#8217;ll need one more package before we get to our code. In order to determine the <em>locales<\/em> or languages of a country, we&#8217;ll use Peter Kahl&#8217;s <code>country-to-locale<\/code> package. We can install it using Composer as well.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"js\">composer require peterkahl\/country-to-locale<\/pre>\n<h3><span class=\"ez-toc-section\" id=\"the-ip-address-locale-detector-class\"><\/span>The IP Address Locale Detector Class<span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>With our setup in place, we can get to our own class, <code>IpAddressLocaleDetector<\/code>.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"php\">&lt;?php\nrequire '..\/vendor\/autoload.php';\nuse GeoIp2\\Database\\Reader;\nuse peterkahl\\locale\\locale;\nclass IpAddressLocaleDetector\n{\n  const MAX_MIND_DB_FILEPATH =\n    __DIR__ . '\/GeoLite2-Country_20200121\/GeoLite2-Country.mmdb';\n  private static $maxMindDbReader;\n  public static function detect()\n  {\n    $ipAddress = static::getIpAddress();\n    try {\n      $record = static::getMaxMindDbReader()-&gt;country($ipAddress);\n      $locales = locale::country2locale($record-&gt;country-&gt;isoCode);\n      $normalizedLocales = str_replace('_', '-', $locales);\n      return explode(',', $normalizedLocales);\n    } catch (Exception $ex) {\n      return null;\n    }\n  }\n  private static function getIpAddress()\n  {\n    return $_SERVER['REMOTE_ADDR'];\n  }\n  private static function getMaxMindDbReader()\n  {\n    if (static::$maxMindDbReader == null) {\n      static::$maxMindDbReader = new Reader(static::MAX_MIND_DB_FILEPATH);\n    }\n    return static::$maxMindDbReader;\n  }\n}<\/pre>\n<p>Our class is relatively straightforward. Much like <code>HttpAcceptLanguageHeaderLocaleDetector<\/code>, it has one public method, <code>detect()<\/code>, which does the following:<\/p>\n<ol>\n<li>Get the request&#8217;s IP Address from the global <code>$_SERVER<\/code> array.<\/li>\n<li>Feeds this IP address to the MaxMind database <code>Reader<\/code>&#8216;s <code>country<\/code> method, which attempts to geolocate a country based on the IP address.<\/li>\n<li>Uses Peter Kahl&#8217;s <code>locale::country2locale()<\/code> to get the languages of the given country.<\/li>\n<li>Normalizes the acquired locales, so that <code>\"en_CA,ar_EG\"<\/code> becomes <code>\"en-CA,ar-EG\"<\/code>.<\/li>\n<li>Returns the locales it normalized as an array, e.g. <code>[\"en-CA\", \"ar-EG\"]<\/code>.<\/li>\n<\/ol>\n<p>\ud83d\udcd6 <em>Go deeper \u00bb<\/em> The MaxMind <code>Reader<\/code> has many more methods. Check out the <a href=\"https:\/\/maxmind.github.io\/GeoIP2-php\/\">official API documentation<\/a> if you want to dive a bit deeper into the info available in the MaxMind databases.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"server-side-cascading-locale-detection\"><\/span>Server-side: Cascading Locale Detection<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>Given the two server-side detection strategies we covered above, we can write a little <code>detect_user_locales()<\/code> function that can attempt the HTTP header strategy first.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"php\">&lt;?php\nrequire '.\/HttpAcceptLanguageHeaderLocaleDetector.php';\nrequire '.\/IpAddressLocaleDetector.php';\nfunction detect_user_locales()\n{\n  $locales = HttpAcceptLanguageHeaderLocaleDetector::detect();\n  if (count($locales) == 0) {\n    $locales = IPAddressLocaleDetector::detect();\n  }\n  if (count($locales) == 0) {\n    \/\/ fall back on some default locale, English in this case\n    $locales = ['en'];\n  }\n  return $locales;\n}<\/pre>\n<p>If HTTP Header detection fails, <code>detect_user_locales()<\/code> will try IP geolocation detection. If the latter bears no fruit, the function will fall back on some default locale.<br \/>\nIf handled carefully, detecting the user&#8217;s locale can help provide a better user experience in our web apps. Thankfully, the <code>navigator.languages<\/code> object and <code>Accept-Langauge<\/code> HTTP header are available to reduce our guesswork when it comes to locale detection.<\/p>\n<p>If you and your team are working on an internationalized web app, check out Phrase for a professional, developer-friendly i18n platform. Featuring a flexible CLI and API, translation syncing with GitHub and Bitbucket integration, over-the-air (OTA) translations, and much more, Phrase has your i18n covered, so you can focus on your business logic.<\/p>\n<p>Check out all <a href=\"https:\/\/phrase.com\/roles\/developers\/\">Phrase features for developers<\/a> and see for yourself how it can streamline your software localization workflows.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>One of the most common issues in web app development is detecting a user&#8217;s locale. This is how to do it the right way.<\/p>\n","protected":false},"author":41,"featured_media":2612,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"_stopmodifiedupdate":false,"_modified_date":"","_searchwp_excluded":"","footnotes":""},"categories":[40],"class_list":["post-9658","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-software-localization"],"acf":[],"_links":{"self":[{"href":"https:\/\/phrase.com\/wp-json\/wp\/v2\/posts\/9658"}],"collection":[{"href":"https:\/\/phrase.com\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/phrase.com\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/phrase.com\/wp-json\/wp\/v2\/users\/41"}],"replies":[{"embeddable":true,"href":"https:\/\/phrase.com\/wp-json\/wp\/v2\/comments?post=9658"}],"version-history":[{"count":8,"href":"https:\/\/phrase.com\/wp-json\/wp\/v2\/posts\/9658\/revisions"}],"predecessor-version":[{"id":94262,"href":"https:\/\/phrase.com\/wp-json\/wp\/v2\/posts\/9658\/revisions\/94262"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/phrase.com\/wp-json\/wp\/v2\/media\/2612"}],"wp:attachment":[{"href":"https:\/\/phrase.com\/wp-json\/wp\/v2\/media?parent=9658"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/phrase.com\/wp-json\/wp\/v2\/categories?post=9658"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}