As discussed many times on slack Laravel today needs a full-featured support for easily making multilingual websites out of the box. Right now there are only translation files and App::set/getLocale() and many core features missing:
- Laravel itself is not setting locale. It automatically can be done by 1st URI segment or by subdomain.
- Routes, URLs, requests can't be easily used with translated URLs containing 1st segment like "/de", for example, redirect to url when you don't need to manually pass a locale string.
- There is option to set default locale and fallback locale, but no way to set additional locales supported by application.
- Laravel still uses languages instead of locales, for example, "lang" folder and not "locale" folder, "en" folder and not "en_US". There were many thoughts that it must be renamed. However, I personally not a fan of doing ab_CD and having /ab-cd/ in URL all the time. Combining languages and locales together would be the best choice. FOr example, if user wants to see page in en_GB and app doesn't support this locale we can search in language itself, "en" in this case.
- From SEO point of view each URL should be unique. Same page in different language is a different page. Default language should not contain 1st URI segment or subdomain.
- Switching locale system (change language menu).
- Detecting user agent's preffered locale and redirecting if required.
- Detecting auth user's profile locale settings if exists and redirecting if required.
- Translated routes.
- Translated models. Probably not needed in Laravel itself.
Now about implementation:
This is codebase I am currently using. RIght now it works only with locales set as 1st URI segment and does not works with subdomains. And it does not handle translated models.
A. New config app.additional_locales
which is an array of supported languages or locales (does not include default locale). For default locale app.fallback_locale
is used.
B. New methods in Application
class:
/**
* Get locale prefix for URI.
* For additional locales will return slash and locale name, for example "/de"
* For default locale will return an empty string ""
* Result from this method used to easily add locale to any URL
* If first param is passed, will check against this locale. Current app locale used by default.
*
* @param null $locale_to
* @return string
*/
public function getLocalePrefix($locale_to = null)
{
if ($locale_to === null) {
$locale = self::getLocale();
} else {
$locale = $locale_to;
}
if ($locale === config('app.fallback_locale')) {
$lang_prefix = '';
} else {
$lang_prefix = '/' . $locale;
}
return $lang_prefix;
}
/**
* Get all locales supported by app (default and additional)
*
* @return array
*/
public function getSupportedLocales() : array
{
$app_locales = config('app.additional_locales');
$app_locales[] = config('app.fallback_locale');
return $app_locales;
}
/**
* Check if app supports $locale
* If second param $lang is true then $locale will be set to ISO2 format, for example, "en_GB" will be "en"
*
* @param string $locale
* @param bool $lang = false
* @return bool
*/
public function checkLocale(string $locale, $lang = false) : bool
{
if ($lang) {
$locale = substr($locale, 0, 2);
}
return in_array($locale, $this->getSupportedLocales());
}
/**
* Same as checkLocale() but instead of returning bool returns $locale if supported or default locale instead.
*
* @param string $locale
* @param bool $lang = false
* @return string
*/
public function checkAndGetLocale(string $locale, $lang = false) : string
{
if ($lang) {
$locale = substr($locale, 0, 2);
}
if (!in_array($locale, $this->getSupportedLocales())) {
return $this->getDefaultLocale();
} else {
return $locale;
}
}
/**
* @return string
*/
public function getDefaultLocale() : string
{
return config('app.fallback_locale');
}
/**
* @return array
*/
public function getAdditionalLocales() : array
{
return config('app.additional_locales');
}
C. New LocaleServiceProvider
(can be placed in AppServiceProvider also) which sets app locale.
public function boot()
{
// set app locale
if (in_array($s1 = Request::segment(1), config('app.additional_locales'))) {
App::setLocale($s1);
}
}
D. Locale prefix added in RouteServiceProvider
:
public function map(Router $router)
{
$router->group(['prefix' => App::getLocalePrefix(), 'namespace' => $this->namespace], function ($router) {
require app_path('Http/Routes/routes.php');
});
}
E. New lang array to store translated routes, for example for en - /lang/en/routes.php
F. New helpers
:
function route_url($route)
{
return url('/' . trans('routes.' . $route));
}
// used when swithcing language or building an URL for different locale
// for example, /about can be translated to /de/about
// or if translated route is used - /about-us to /de/uber-uns
function trans_url($locale = null)
{
if ($locale === null) {
$locale = config('app.fallback_locale');
}
$route_langs = Lang::get('routes');
$detected_route = '';
foreach ($route_langs as $route => $route_lang) {
if (strpos(Request::getPathInfo(), $route_lang) !== false) {
$detected_route = $route;
break;
}
}
$new_route_langs = Lang::get('routes', [], $locale);
if ($detected_route !== '') {
return App::getLocalePrefix($locale) . '/' . $new_route_langs[$detected_route];
} else {
$segments = Request::segments();
if (isset($segments[0]) && '/' . $segments[0] === App::getLocalePrefix()) {
unset($segments[0]);
//dd(App::getLocalePrefix($locale) .'/' . implode('/', $segments));
}
if (App::getLocalePrefix($locale) !== '') {
return App::getLocalePrefix($locale) . '/' . implode('/', $segments);
} else {
return App::getLocalePrefix($locale) . '/' . implode('/', $segments);
}
}
}
G. Since all logic can't be done in service provider, for example auth user is not initiated yet or redirect can't be used there should be also new Middleware
- RedirectToLocale
which detects user agent's preffered locale and logged in user's locale. Field (column) can be configured.
This approach also does not use sessions/cookies. It depends on HTTP referer header which is always sent by application. When user enters site for the first time, he can be redirected, but, for example, when user switches language from menu he should not be redirected again.
public function handle($request, Closure $next, $guard = null)
{
if (App::getLocalePrefix() === '') {
if (isset($_SERVER['HTTP_REFERER']) && strpos($_SERVER['HTTP_REFERER'], $_SERVER['HTTP_HOST']) !== false) {
// if user was redirected from app itself, do not change locale and redirect him again
// for example, user changed language from menu and was already redirected by this middleware
} else {
// if user logged in, redirect to locale from user account settings
if (Auth::guard($guard)->check() && Auth::user()->profile_locale !== config('app.fallback_locale')) {
return redirect(trans_url(Auth::user()->profile_locale));
} elseif (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
// if not logged in, check browser's preffered language
$lang = substr($_SERVER['HTTP_ACCEPT_LANGUAGE'], 0, 2);
if (in_array($lang, config('app.additional_locales'))) {
return redirect($lang);
}
}
}
}
return $next($request);
}
H. Extended Request::is()
public function is()
{
foreach (func_get_args() as $pattern) {
$pattern = App::getLocalePrefix() . $pattern;
if (Str::is($pattern, urldecode($this->getPathInfo()))) {
return true;
}
}
return false;
}
I. Extended UrlGenerator::to()
public function to($path, $extra = [], $secure = null)
{
// if $path is external, do not add lang prefix
if (strpos($path, '://') === false) {
$path = App::getLocalePrefix() . $path;
}
return parent::to($path, $extra, $secure);
}