Mar112012

Persistent localized routes with Lithium

Just a few minutes ago I got pinged through IRC asking if I could provide a quick answer. The question asked by m99 (thanks for the blog post inspiration!) was if there was an easy way to ensure localized routes with Lithium. As with any challenge you face with lithium, the answer ended up being dead simple.

So let’s make sure we understand what we want to do first by enumerating a list of objectives:

  • We want to make sure that our application URLs are prefixed with a locale that identifies the language, and region of choice.
  • If there is no locale in the URL, we need to figure out what the best choice is for the current visitor, and point them to the same URL they are intending to reach, but with the locale prefix added.
  • We want to make this prefix persistent (search engine mumble jumble.) So once a locale prefix is set, all links within the application should contain the prefix.

Believe it or not, all this is pretty much offered out-of-the-box using nothing but the following Lithium features

So let’s get to work. First thing we want to do is ensure that the g11n.php file is loaded as part of the bootstrap process. So edit your app/config/bootstrap.php file and uncomment the line that requires the app/config/bootstrap/g11n.php file.

Next we want to define which languages are supported in our application. Normally the list of supported languages is defined in the g11n.php file through environment variables, as you can then use that to build up a language menu in your views, or test more languages on different environments. Edit the languages defined in your app/config/bootstrap/g11n.php file. For example my list of languages looks like the following:

$locale = 'en_US';
$locales = array(
	'en_US' => 'English',
	'es_AR' => 'Español'
);

Environment::set('production', compact('locale', 'locales'));
Environment::set('development', compact('locale', 'locales'));
Environment::set('test', array('locale' => 'en', 'locales' => array('en' => 'English')));

Now we want to make sure we allow locales to be specified as part of an URL. Edit your app/config/routes.php file and add the following route right before any of your custom routes (so it should pretty much stand as the first route defined):

Router::connect('/{:locale:[a-z]{2}_[A-Z]{2}}/{:args}', array(), array('continue' => true, 'persist' => array('locale')));

We now need to make sure that if there is no locale specified, or if the locale is not valid, we prefix the current action with a valid locale. We also want to be smart and figure out what’s the best language for the current request, and if there is a language match, but not a region match, we still maintain their desired language (for example, in our g11n.php file we defined es_AR as the only spanish localization, so if someone with an es_ES locale shows up, we still want to take them to a Spanish version of our website.)

Go back to edit your app/config/bootstrap/g11n.php file, and look for a closure bound to a variable named $setLocale right at the very bottom. This closure normally looks concise, but replace it all with the following:

UPDATE: The best matching code that was taking care of our example es_ES -> es_AR matching scenario has been greatly simplified by the recent pull request merged into Lithium on 6c95cb80bc

$setLocale = function($self, $params, $chain) {
	$redirect = false;
	$locales = array_keys(Environment::get('locales'));
	if (!$params['request']->locale()) {
		$redirect = true;
		$locale = Locale::preferred($params['request'], $locales);
	} else {
		$locale = $params['request']->locale();
	}

	if (empty($locale) || !in_array($locale, $locales)) {
		$redirect = true;
		if (!empty($locale)) {
			try {
				$locale = Locale::lookup($locales, $locale);
			} catch(\InvalidArgumentException $e) {
				$locale = null;
			}
		} else {
			$locale = null;
		}

		if (empty($locale)) {
			$locale = Environment::get('locale');
		}
	}

	if ($redirect) {
		$params['request']->locale($locale);
		$url = compact('locale') + $params['request']->params;
		return function() use($url) {
			return new \lithium\action\Response(array(
				'location' => $url
			));
		};
	}

	Environment::set(true, array('locale' => $params['request']->locale()));
	return $chain->next($self, $params, $chain);
};

Let’s do a quick summary of what the code above does:

  • Lines 02-08: try to get the locale from the current prefix. If no prefix is provided, do it by figuring out what would be the best locale for the current request.
  • Lines 11-26: what to do if there is no locale (something is weird in the request and we can’t figure out the best locale for the client.), or if the locale specified is not within our list of active locales. The interesting part here are lines 13-21, where we do the best-match discovery I mentioned earlier. So if someone is requesting an en_UK locale, and we only have en_US, we make them use en_US. This block of code ends with a simple fail over: if no locale was found to be best for our client, we use the default locale (lines 23-25)
  • Lines 28-36: If we have figured a new locale, we redirect the user to the current URL, but setting the new locale.

That’s it! Try accessing your application with no locale defined. For example if you try to reach http://myapp/signup you will be redirected to http://myapp/en_US/signup, and all your links will be prefixed with the en_US.



Leave a Comment

2 Comments to "Persistent localized routes with Lithium"

  1. May072012 at 1:27 pm

    rapzo wrote:

    Great how-to, congrats!
    Although, i think the redirection is unnecessary. Setting $params['request']->location = $url; does the trick. Just don’t know if it is the best practice.

    Cheers.

  2. Sep042012 at 11:02 am

    Max wrote:

    Thanks for this!

    I found the following route setting to work better in most cases:

    Router::connect(‘/{:locale:[a-z]{2}(_[A-Z]{2})?}/{:args}’, array(), array(‘continue’ => true, ‘persist’ => array(‘locale’)));

    Best regards,
    Max.

 
Powered by Wordpress and MySQL. Clauz's design for by Cricava