├── .travis.yml ├── .editorconfig ├── LanguageChangedEvent.php ├── composer.json ├── README.md └── UrlManager.php /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | dist: trusty 3 | php: 4 | - "7.4" 5 | - "7.3" 6 | - "7.2" 7 | - "7.1" 8 | - "7.0" 9 | - "5.6" 10 | 11 | install: 12 | - sudo apt-get update 13 | - travis_retry composer self-update && composer --version 14 | - travis_retry composer install --no-interaction --no-progress 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | indent_style = space 10 | indent_size = 4 11 | trim_trailing_whitespace = true 12 | 13 | [*.php] 14 | ij_php_space_before_short_closure_left_parenthesis = false 15 | ij_php_space_after_type_cast = true 16 | 17 | [*.md] 18 | trim_trailing_whitespace = false 19 | 20 | [*.yml] 21 | indent_size = 2 22 | -------------------------------------------------------------------------------- /LanguageChangedEvent.php: -------------------------------------------------------------------------------- 1 | **IMPORTANT:** If you upgraded from version 1.0.* you have to modify your configuration. 14 | > Please check the section on [Upgrading](#upgrading) below. 15 | 16 | ## Features 17 | 18 | With this extension you can use URLs that contain a language code like: 19 | 20 | /en/some/page 21 | /de/some/page 22 | http://www.example.com/en/some/page 23 | http://www.example.com/de/some/page 24 | 25 | You can also configure friendly names if you want: 26 | 27 | http://www.example.com/english/some/page 28 | http://www.example.com/deutsch/some/page 29 | 30 | The language code is automatically added whenever you create a URL, and 31 | read back when a URL is parsed. For best user experience the language is 32 | autodetected from the browser settings, if no language is used in the URL. 33 | The user can still access other languages, though, simply by calling a URL 34 | with another language code. 35 | 36 | The last requested language is also persisted in the user session and 37 | in a cookie. So if the user tries to access your site without a language 38 | code in the URL, he'll get redirected to the language he had used on 39 | his last visit. 40 | 41 | All the above (and more) is configurable of course. 42 | 43 | 44 | ## Installation 45 | 46 | Install the package through [composer](http://getcomposer.org): 47 | 48 | composer require codemix/yii2-localeurls 49 | 50 | And then add this to your application configuration: 51 | 52 | ```php 53 | [ 59 | // ... 60 | 61 | // Override the urlManager component 62 | 'urlManager' => [ 63 | 'class' => 'codemix\localeurls\UrlManager', 64 | 65 | // List all supported languages here 66 | // Make sure, you include your app's default language. 67 | 'languages' => ['en-US', 'en', 'fr', 'de', 'es-*'], 68 | ] 69 | 70 | // ... 71 | ] 72 | ]; 73 | ``` 74 | 75 | Now you're ready to use the extension. 76 | 77 | > Note: You can still configure custom URL rules as usual. Just ignore any `language` parameter 78 | > in your URL rules as it will get removed before parsing and added after creating a URL. 79 | 80 | > Note 2: The language code will be removed from the 81 | > [pathInfo](http://www.yiiframework.com/doc-2.0/yii-web-request.html#$pathInfo-detail). 82 | 83 | ## Mode of operation and configuration 84 | 85 | ### Creating URLs 86 | 87 | All created URLs will contain the code of the current application language. So if the 88 | language was detected to be `de` and you use: 89 | 90 | ```php 91 | 92 | 93 | ``` 94 | 95 | you'll get URLs like 96 | 97 | /de/demo/action 98 | 99 | To create a link to switch the application to a different language, you can 100 | explicitly add the `language` URL parameter: 101 | 102 | ```php 103 | 'fr']) ?> 104 | 'fr']) ?> 105 | ``` 106 | 107 | This will give you a URL like 108 | 109 | /fr/demo/action 110 | 111 | > Note: The URLs may look different if you use custom URL rules. In this case 112 | > the language parameter is always prepended/inserted to the final relative/absolute URL. 113 | 114 | If for some reason you want to use a different name than `language` for that URL 115 | parameter you can configure it through the `languageParam` option of the `urlManager` 116 | component. 117 | 118 | ### Default Language 119 | 120 | The default language is configured via the 121 | [language](http://www.yiiframework.com/doc-2.0/yii-base-application.html#$language-detail) 122 | parameter of your application configuration. You always have to include this 123 | language in the `$languages` configuration (see below). 124 | 125 | By default the URLs for the default language won't contain any language code. For example: 126 | 127 | / 128 | /some/page 129 | 130 | If the site is accessed with URLs containing the default language code, the visitor gets 131 | redirected to the URLs without language code. For example if default language is `fr`: 132 | 133 | /fr/ -> Redirect to / 134 | /fr/some/page -> Redirect to /some/page 135 | 136 | If `enableDefaultLanguageUrlCode` is changed to `true` it's vice versa. The default language 137 | is now treated like any other configured language. Requests with URL that don't contain a 138 | language code are no longer accessible: 139 | 140 | /fr 141 | /fr/some/page 142 | / -> Redirect to /fr 143 | /some/page -> Redirect to /fr/some/page 144 | 145 | ### Language Configuration 146 | 147 | All languages **including the default language** must be configured in the `languages` 148 | parameter of the `localeUrls` component: 149 | 150 | 'languages' => ['en-US', 'en-UK', 'en', 'fr', 'de-AT', 'de'], 151 | 152 | > **Note:** If you use country codes, they should always be configured in upper case letters 153 | > as shown above. The URLs will still always use lowercase codes. If a URL with an uppercase 154 | > code like `en-US` is used, the user will be redirected to the lowercase `en-us` variant. 155 | > The application language will always use the correct `en-US` code. If you don't want to 156 | > redirect URLs with lowercase country code, you can set the `keepUppercaseLanguageCode` 157 | > option to `true`. 158 | 159 | If you want your URL to optionally contain *any* country variant you can also use a wildcard pattern: 160 | 161 | 'languages' => ['en-*', 'de-*'], 162 | 163 | Now any URL that matches `en-??` or `de-??` could be used, like `en-us` or `de-at`. 164 | URLs without a country code like `en` and `de` will also still work: 165 | 166 | /en/demo/action 167 | /en-us/demo/action 168 | /en-en/demo/action 169 | /de/demo/action 170 | /de-de/demo/action 171 | /de-at/demo/action 172 | 173 | The URLs with a country code will set the full `ll-CC` code as Yii language whereas the 174 | URLs with a language code only, will lead to `ll` as configured language. 175 | 176 | > **Note:** You don't need this if all you want is a fallback of `de-AT` to `de` for 177 | > languages detected from the browser settings. See the section on [Language Detection](#language-detection) below. 178 | 179 | You can also use friendlier names or aliases in URLs, which are configured like so: 180 | 181 | 'languages' => ['en', 'german' => 'de', 'br' => 'pt-BR'], 182 | 183 | ```php 184 | 'de']) ?> 185 | ``` 186 | 187 | This will give you URLs like 188 | 189 | /german/demo/action 190 | /br/demo/action 191 | 192 | and set the respective language to `de` or `pt-PR` if matched. 193 | 194 | ### Persistence 195 | 196 | The last language a visitor has used will be stored in the user session and in a cookie. 197 | If the user visits your site again without a language code, he will get redirected 198 | to the stored language. 199 | 200 | For example, if the user first visits: 201 | 202 | /de/some/page 203 | 204 | then after some time comes back to one of the following URLs: 205 | 206 | /some/page -> Redirect to /de/some/page 207 | / -> Redirect to /de/ 208 | /dk/some/page 209 | 210 | In the last case, `dk` will be stored as last language. 211 | 212 | Persistence is enabled by default and can be disabled by setting `enableLanguagePersistence` 213 | to `false` in the `localeUrls` component. 214 | 215 | You can modify other persistence settings with: 216 | 217 | * `languageCookieDuration`: How long in seconds to store the language information in a cookie. 218 | Set to `false` to disable the cookie. 219 | * `languageCookieName`: The name of the language cookie. Default is `_language`. 220 | * `languageCookieOptions`: Other options to set on the language cookie. 221 | * `languageSessionKey`: The name of the language session key. Default is `_language`. 222 | Since 1.6.0 this can also be set to `false` to not use the session at all. 223 | 224 | #### Reset To Default Language 225 | 226 | You'll notice, that there's one problem, if `enableDefaultLanguageUrlCode` is `false` (which 227 | is the default) and the user has e.g. stored `de` as last language. How can we now 228 | access the site in the default language? Because if we try `/` we'd be redirected 229 | to `/de/`. 230 | 231 | The answer is simple: To create a reset URL, you explicitly include the language code 232 | for the default language in the URL. For example if default language is `fr`: 233 | 234 | ```php 235 | 'fr']) ?> 236 | ``` 237 | 238 | /fr/demo/action -> Redirect to /demo/action 239 | 240 | In this case, `fr` will first be stored as last used language before the user is redirected. 241 | 242 | If you explicitely need to create a URL to the default language without any language code, 243 | you can also pass an empty string as language: 244 | 245 | ```php 246 | '']) ?> 247 | ``` 248 | 249 | This will give you: 250 | 251 | /demo/action 252 | 253 | 254 | #### Language Change Event 255 | 256 | When persistence is enabled, the component will fire a `languageChanged` event 257 | whenever the language stored in session or cookie changes. Here's an example 258 | how this can be used to track user languages in the database: 259 | 260 | ```php 261 | [ 264 | 'class' => 'codemix\localeurls\UrlManager', 265 | 'languages' => ['en', 'fr', 'de'], 266 | 'on languageChanged' => `\app\components\User::onLanguageChanged', 267 | ] 268 | ``` 269 | 270 | The static class method in `User` could look like this: 271 | 272 | ```php 273 | language: new language 277 | // $event->oldLanguage: old language 278 | 279 | // Save the current language to user record 280 | $user = Yii::$app->user; 281 | if (!$user->isGuest) { 282 | $user->identity->language = $event->language; 283 | $user->identity->save(); 284 | } 285 | } 286 | ``` 287 | > **Note:** A language may already have been selected before a user logs in or 288 | > signs up. So you should also save or update the language in these cases. 289 | 290 | 291 | ### Language Detection 292 | 293 | If a user visits your site for the first time and there's no language stored in session 294 | or cookie (or persistence is turned off), then the language is detected from the visitor's 295 | browser settings. If one of the preferred languages matches your language, it will be 296 | used as application language (and also persisted if persistence is enabled). 297 | 298 | To disable this, you can set `enableLanguageDetection` to `false`. It's enabled by default. 299 | 300 | If the browser language contains a country code like `de-AT` and you only have `de` in your 301 | `$languages` configuration, it will fall back to that language. Only if you've used a wildcard 302 | like `de-*` or have explicitly configured `de-AT` or an alias like `'at' => 'de-AT'`, the 303 | browser language including the country code will be used. 304 | 305 | Let's look at an example configuration to better understand, how the `$languages` configuration 306 | affects language detection and the created URLs. 307 | 308 | ```php 309 | 'languages' => [ 310 | 'en', 311 | 'at' => 'de-AT', 312 | 'de', 313 | 'pt-*' 314 | ], 315 | ``` 316 | 317 | Now say a user visits your site for the first time. Depending on his browser settings, he will 318 | be directed to different URLs. 319 | 320 | Accept-Language Header | Resulting URL code | Resulting Yii language 321 | ------------------------------------|-----------------------|----------------------- 322 | `en`, `en-us`, `en-US`, ... | `/en` | `en` 323 | `de-at`, `de-AT` | `/at` | `de-AT` 324 | `de`, `de-de`, `de-DE`, `de-ch`, ...| `/de` | `de` 325 | `pt-BR`, `pt-br` | `/pt-br` | `pt-BR` 326 | `pt-PT`, `pt-pt` | `/pt-pt` | `pt-PT` 327 | Any other `pt-CC` code | `/pt-cc` | `pt-CC` 328 | `pt` | `/pt` | `pt` 329 | 330 | 331 | #### Detection via GeoIP server module 332 | 333 | Since 1.7.0 language can also be detected via the webserver's GeoIP module. 334 | Note though that this only happens if no valid language was found in the 335 | browser settings. 336 | 337 | For this feature to work the related GeoIp module must already be installed and 338 | it must provide the country code in a server variable in `$_SERVER`. You can 339 | configure the key in `$geoIpServerVar`. The default is `HTTP_X_GEO_COUNTRY`. 340 | 341 | To enable this feature, you have to provide a list of GeoIp country codes and 342 | index them by the corresponding language that should be set: 343 | 344 | ```php 345 | 'geoIpLanguageCountries' => [ 346 | 'de' => ['DEU', 'AUT'], 347 | 'pt' => ['PRT', 'BRA'], 348 | ], 349 | ``` 350 | 351 | 352 | ### Excluding Routes / URLs 353 | 354 | You may want to disable the language processing for some routes and URLs with the 355 | `$ignoreLanguageUrlPatterns` option: 356 | 357 | ```php 358 | [ 360 | // route pattern => url pattern 361 | '#^site/(login|register)#' => '#^(signin|signup)#', 362 | '#^api/#' => '#^api/#', 363 | ], 364 | ``` 365 | 366 | Both, keys and values are regular expressions. The keys are patterns that match routes 367 | to exclude from language processing during *URL creation*, whereas the values are patterns 368 | for [pathInfo](http://www.yiiframework.com/doc-2.0/yii-web-request.html#$pathInfo-detail) 369 | that should be excluded during *URL parsing*. 370 | 371 | > Note: Keys and values don't necessarily have to relate to each other. It's just for 372 | > convenience, that the configuration is combined into a single option. 373 | 374 | ## Example Language Selection Widget 375 | 376 | There's no widget for language selection included, because there are simply too many options 377 | for the markup and behavior of such a widget. But it's very easy to build. Here's the basic idea: 378 | 379 | ```php 380 | controller->route; 393 | $appLanguage = Yii::$app->language; 394 | $params = $_GET; 395 | $this->_isError = $route === Yii::$app->errorHandler->errorAction; 396 | 397 | array_unshift($params, '/' . $route); 398 | 399 | foreach (Yii::$app->urlManager->languages as $language) { 400 | $isWildcard = substr($language, -2) === '-*'; 401 | if ( 402 | $language === $appLanguage || 403 | // Also check for wildcard language 404 | $isWildcard && substr($appLanguage, 0, 2) === substr($language, 0, 2) 405 | ) { 406 | continue; // Exclude the current language 407 | } 408 | if ($isWildcard) { 409 | $language = substr($language, 0, 2); 410 | } 411 | $params['language'] = $language; 412 | $this->items[] = [ 413 | 'label' => self::label($language), 414 | 'url' => $params, 415 | ]; 416 | } 417 | parent::init(); 418 | } 419 | 420 | public function run() 421 | { 422 | // Only show this widget if we're not on the error page 423 | if ($this->_isError) { 424 | return ''; 425 | } else { 426 | return parent::run(); 427 | } 428 | } 429 | 430 | public static function label($code) 431 | { 432 | if (self::$_labels === null) { 433 | self::$_labels = [ 434 | 'de' => Yii::t('language', 'German'), 435 | 'fr' => Yii::t('language', 'French'), 436 | 'en' => Yii::t('language', 'English'), 437 | ]; 438 | } 439 | 440 | return isset(self::$_labels[$code]) ? self::$_labels[$code] : null; 441 | } 442 | } 443 | ``` 444 | 445 | ## Upgrading 446 | 447 | ### Changes from 1.0.* to 1.1.* 448 | 449 | If you upgrade from a 1.0.* version you'll have to modify your configuration. There no 450 | longer is a `localeUrls` component now. Instead everything was merged into our custom 451 | `urlManager` component. So you should move any configuration for the `localeUrls` component 452 | into the `urlManager` component. 453 | 454 | Two options also have been renamed for more clarity: 455 | 456 | * `enableDefaultSuffix` is now `enableDefaultLanguageUrlCode` 457 | * `enablePersistence` is now `enableLanguagePersistence` 458 | 459 | So if your configuration looked like this before: 460 | 461 | ```php 462 | ['localeUrls'], 465 | 'components' => [ 466 | 'localeUrls' => [ 467 | 'languages' => ['en-US', 'en', 'fr', 'de', 'es-*'], 468 | 'enableDefaultSuffix' => true, 469 | 'enablePersistence' => false, 470 | ], 471 | 'urlManager' => [ 472 | 'class' => 'codemix\localeurls\UrlManager', 473 | ] 474 | ] 475 | ]; 476 | ``` 477 | 478 | you should now change it to: 479 | 480 | ```php 481 | [ 484 | 'urlManager' => [ 485 | 'class' => 'codemix\localeurls\UrlManager', 486 | 'languages' => ['en-US', 'en', 'fr', 'de', 'es-*'], 487 | 'enableDefaultLanguageUrlCode' => true, 488 | 'enableLanguagePersistence' => false, 489 | ] 490 | ] 491 | ]; 492 | ``` 493 | -------------------------------------------------------------------------------- /UrlManager.php: -------------------------------------------------------------------------------- 1 | => , e.g. 'english' => 'en'. 26 | */ 27 | public $languages = []; 28 | 29 | /** 30 | * @var bool whether to enable locale URL specific features 31 | */ 32 | public $enableLocaleUrls = true; 33 | 34 | /** 35 | * @var bool whether the default language should use an URL code like any 36 | * other configured language. 37 | * 38 | * By default this is `false`, so for URLs without a language code the 39 | * default language is assumed. In addition any request to an URL that 40 | * contains the default language code will be redirected to the same URL 41 | * without a language code. So if the default language is `fr` and a user 42 | * requests `/fr/some/page` he gets redirected to `/some/page`. This way 43 | * the persistent language can be reset to the default language. 44 | * 45 | * If this is `true`, then an URL that does not contain any language code 46 | * will be redirected to the same URL with default language code. So if for 47 | * example the default language is `fr`, then any request to `/some/page` 48 | * will be redirected to `/fr/some/page`. 49 | * 50 | */ 51 | public $enableDefaultLanguageUrlCode = false; 52 | 53 | /** 54 | * @var bool whether to detect the app language from the HTTP headers (i.e. 55 | * browser settings). Default is `true`. 56 | */ 57 | public $enableLanguageDetection = true; 58 | 59 | /** 60 | * @var bool whether to store the detected language in session and 61 | * (optionally) a cookie. If this is `true` (default) and a returning user 62 | * tries to access any URL without a language prefix, he'll be redirected 63 | * to the respective stored language URL (e.g. /some/page -> 64 | * /fr/some/page). 65 | */ 66 | public $enableLanguagePersistence = true; 67 | 68 | /** 69 | * @var bool whether to keep upper case language codes in URL. Default is 70 | * `false` wich will e.g. redirect `de-AT` to `de-at`. 71 | */ 72 | public $keepUppercaseLanguageCode = false; 73 | 74 | /** 75 | * @var string|bool the name of the session key that is used to store the 76 | * language. If `false` no session is used. Default is '_language'. 77 | */ 78 | public $languageSessionKey = '_language'; 79 | 80 | /** 81 | * @var string the name of the language cookie. Default is '_language'. 82 | */ 83 | public $languageCookieName = '_language'; 84 | 85 | /** 86 | * @var int number of seconds how long the language information should be 87 | * stored in cookie, if `$enableLanguagePersistence` is true. Set to 88 | * `false` to disable the language cookie completely. Default is 30 days. 89 | */ 90 | public $languageCookieDuration = 2592000; 91 | 92 | /** 93 | * @var array configuration options for the language cookie. Note that 94 | * `$languageCookieName` and `$languageCookeDuration` will override any 95 | * `name` and `expire` settings provided here. 96 | */ 97 | public $languageCookieOptions = []; 98 | 99 | /** 100 | * @var array list of route and URL regex patterns to ignore during 101 | * language processing. The keys of the array are patterns for routes, the 102 | * values are patterns for URLs. Route patterns are checked during URL 103 | * creation. If a pattern matches, no language parameter will be added to 104 | * the created URL. URL patterns are checked during processing incoming 105 | * requests. If a pattern matches, the language processing will be skipped 106 | * for that URL. Examples: 107 | * 108 | * ~~~php 109 | * [ 110 | * '#^site/(login|register)#' => '#^(login|register)#' 111 | * '#^api/#' => '#^api/#', 112 | * ] 113 | * ~~~ 114 | */ 115 | public $ignoreLanguageUrlPatterns = []; 116 | 117 | /** 118 | * @inheritdoc 119 | */ 120 | public $enablePrettyUrl = true; 121 | 122 | /** 123 | * @var string if a parameter with this name is passed to any `createUrl()` 124 | * method, the created URL will use the language specified there. URLs 125 | * created this way can be used to switch to a different language. If no 126 | * such parameter is used, the currently detected application language is 127 | * used. 128 | */ 129 | public $languageParam = 'language'; 130 | 131 | /** 132 | * @var string the key in $_SERVER that contains the detected GeoIP country. 133 | * Default is 'HTTP_X_GEO_COUNTRY' as used by mod_geoip in apache. 134 | */ 135 | public $geoIpServerVar = 'HTTP_X_GEO_COUNTRY'; 136 | 137 | /** 138 | * @var array list of GeoIP countries indexed by corresponding language 139 | * code. The default is an empty list which disables GeoIP detection. 140 | * Example: 141 | * 142 | * ~~~php 143 | * [ 144 | * // Set app language to 'ru' for these GeoIp countries 145 | * 'ru' => ['RUS','AZE','ARM','BLR','KAZ','KGZ','MDA','TJK','TKM','UZB','UKR'] 146 | * 147 | * ] 148 | * ~~~ 149 | */ 150 | public $geoIpLanguageCountries = []; 151 | 152 | /** 153 | * @var int the HTTP status code. Default is 302. 154 | */ 155 | public $languageRedirectCode = 302; 156 | 157 | /** 158 | * @var string the language that was initially set in the application 159 | * configuration 160 | */ 161 | protected $_defaultLanguage; 162 | 163 | /** 164 | * @var \yii\web\Request 165 | */ 166 | protected $_request; 167 | 168 | /** 169 | * @var bool whether locale URL was processed 170 | */ 171 | protected $_processed = false; 172 | 173 | /** 174 | * @inheritdoc 175 | */ 176 | public function init() 177 | { 178 | if ($this->enableLocaleUrls && $this->languages) { 179 | if (!$this->enablePrettyUrl) { 180 | throw new InvalidConfigException('Locale URL support requires enablePrettyUrl to be set to true.'); 181 | } 182 | } 183 | $this->_defaultLanguage = Yii::$app->language; 184 | parent::init(); 185 | } 186 | 187 | /** 188 | * @return string the `language` option that was initially set in the 189 | * application config file, before it was modified by this component. 190 | */ 191 | public function getDefaultLanguage() 192 | { 193 | return $this->_defaultLanguage; 194 | } 195 | 196 | /** 197 | * @inheritdoc 198 | */ 199 | public function parseRequest($request) 200 | { 201 | if ($this->enableLocaleUrls && $this->languages) { 202 | $this->_request = $request; 203 | $process = true; 204 | if ($this->ignoreLanguageUrlPatterns) { 205 | $pathInfo = $request->getPathInfo(); 206 | foreach ($this->ignoreLanguageUrlPatterns as $k => $pattern) { 207 | if (preg_match($pattern, $pathInfo)) { 208 | $message = "Ignore pattern '$pattern' matches '$pathInfo.' Skipping language processing."; 209 | Yii::trace($message, __METHOD__); 210 | $process = false; 211 | } 212 | } 213 | } 214 | if ($process && !$this->_processed) { 215 | // Check if a normalizer wants to redirect 216 | $normalized = false; 217 | if (property_exists($this, 'normalizer') && $this->normalizer!==false) { 218 | try { 219 | parent::parseRequest($request); 220 | } catch (UrlNormalizerRedirectException $e) { 221 | $normalized = true; 222 | } 223 | } 224 | $this->_processed = true; 225 | $this->processLocaleUrl($normalized); 226 | } 227 | } 228 | return parent::parseRequest($request); 229 | } 230 | 231 | /** 232 | * @inheritdoc 233 | */ 234 | public function createUrl($params) 235 | { 236 | if ($this->ignoreLanguageUrlPatterns) { 237 | $params = (array) $params; 238 | $route = trim($params[0], '/'); 239 | foreach ($this->ignoreLanguageUrlPatterns as $pattern => $v) { 240 | if (preg_match($pattern, $route)) { 241 | return parent::createUrl($params); 242 | } 243 | } 244 | } 245 | 246 | if ($this->enableLocaleUrls && $this->languages) { 247 | $params = (array) $params; 248 | 249 | $isLanguageGiven = isset($params[$this->languageParam]); 250 | $language = $isLanguageGiven ? $params[$this->languageParam] : Yii::$app->language; 251 | $isDefaultLanguage = $language === $this->getDefaultLanguage(); 252 | 253 | if ($isLanguageGiven) { 254 | unset($params[$this->languageParam]); 255 | } 256 | 257 | $url = parent::createUrl($params); 258 | 259 | if ( 260 | // Only add language if it's not empty and ... 261 | $language!=='' && ( 262 | 263 | // ... it's not the default language or default language uses URL code ... 264 | !$isDefaultLanguage || $this->enableDefaultLanguageUrlCode || 265 | 266 | // ... or if a language is explicitely given, but only if 267 | // either persistence or detection is enabled. This way a 268 | // "reset URL" can be created for the default language. 269 | $isLanguageGiven && ($this->enableLanguagePersistence || $this->enableLanguageDetection) 270 | ) 271 | ) { 272 | $key = array_search($language, $this->languages); 273 | if (is_string($key)) { 274 | $language = $key; 275 | } 276 | if (!$this->keepUppercaseLanguageCode) { 277 | $language = strtolower($language); 278 | } 279 | 280 | // Calculate the position where the language code has to be inserted 281 | // depending on the showScriptName and baseUrl configuration: 282 | // 283 | // - /foo/bar -> /de/foo/bar 284 | // - /base/foo/bar -> /base/de/foo/bar 285 | // - /index.php/foo/bar -> /index.php/de/foo/bar 286 | // - /base/index.php/foo/bar -> /base/index.php/de/foo/bar 287 | // 288 | $prefix = $this->showScriptName ? $this->getScriptUrl() : $this->getBaseUrl(); 289 | $insertPos = strlen($prefix); 290 | 291 | // Remove any trailing slashes for root URLs 292 | if ($this->suffix !== '/') { 293 | if (count($params) === 1 ) { 294 | // / -> '' 295 | // /base/ -> /base 296 | // /index.php/ -> /index.php 297 | // /base/index.php/ -> /base/index.php 298 | if ($url === $prefix . '/') { 299 | $url = rtrim($url, '/'); 300 | } 301 | } elseif (strncmp($url, $prefix . '/?', $insertPos + 2) === 0) { 302 | // /?x=y -> ?x=y 303 | // /base/?x=y -> /base?x=y 304 | // /index.php/?x=y -> /index.php?x=y 305 | // /base/index.php/?x=y -> /base/index.php?x=y 306 | $url = substr_replace($url, '', $insertPos, 1); 307 | } 308 | } 309 | 310 | // If we have an absolute URL the length of the host URL has to 311 | // be added: 312 | // 313 | // - http://www.example.com 314 | // - http://www.example.com?x=y 315 | // - http://www.example.com/foo/bar 316 | // 317 | if (strpos($url, '://')!==false) { 318 | // Host URL ends at first '/' or '?' after the schema 319 | if (($pos = strpos($url, '/', 8))!==false || ($pos = strpos($url, '?', 8))!==false) { 320 | $insertPos += $pos; 321 | } else { 322 | $insertPos += strlen($url); 323 | } 324 | } 325 | if ($insertPos > 0) { 326 | return substr_replace($url, '/' . $language, $insertPos, 0); 327 | } else { 328 | return '/' . $language . $url; 329 | } 330 | } else { 331 | return $url; 332 | } 333 | } else { 334 | return parent::createUrl($params); 335 | } 336 | } 337 | 338 | /** 339 | * Checks for a language or locale parameter in the URL and rewrites the 340 | * pathInfo if found. If no parameter is found it will try to detect the 341 | * language from persistent storage (session / cookie) or from browser 342 | * settings. 343 | * 344 | * @param bool $normalized whether a UrlNormalizer tried to redirect 345 | */ 346 | protected function processLocaleUrl($normalized) 347 | { 348 | $pathInfo = $this->_request->getPathInfo(); 349 | $parts = []; 350 | foreach ($this->languages as $k => $v) { 351 | $value = is_string($k) ? $k : $v; 352 | if (substr($value, -2) === '-*') { 353 | $lng = substr($value, 0, -2); 354 | $parts[] = "$lng\-[a-z]{2,3}"; 355 | $parts[] = $lng; 356 | } else { 357 | $parts[] = $value; 358 | } 359 | } 360 | // order by length to make longer patterns match before short patterns, e.g. put "en-GB" before "en" 361 | usort($parts, function($a, $b) { 362 | $la = mb_strlen($a); 363 | $lb = mb_strlen($b); 364 | if ($la === $lb) { 365 | return 0; 366 | } 367 | return $la < $lb ? 1 : -1; 368 | }); 369 | $pattern = implode('|', $parts); 370 | if (preg_match("#^($pattern)\b(/?)#i", $pathInfo, $m)) { 371 | $this->_request->setPathInfo(mb_substr($pathInfo, mb_strlen($m[1].$m[2]))); 372 | $code = $m[1]; 373 | if (isset($this->languages[$code])) { 374 | // Replace alias with language code 375 | $language = $this->languages[$code]; 376 | } else { 377 | // lowercase language, uppercase country 378 | list($language,$country) = $this->matchCode($code); 379 | if ($country!==null) { 380 | if ($code === "$language-$country" && !$this->keepUppercaseLanguageCode) { 381 | $this->redirectToLanguage(strtolower($code)); // Redirect ll-CC to ll-cc 382 | } else { 383 | $language = "$language-$country"; 384 | } 385 | } 386 | if ($language === null) { 387 | $language = $code; 388 | } 389 | } 390 | Yii::$app->language = $language; 391 | Yii::trace("Language code found in URL. Setting application language to '$language'.", __METHOD__); 392 | if ($this->enableLanguagePersistence) { 393 | $this->persistLanguage($language); 394 | } 395 | 396 | // "Reset" case: We called e.g. /fr/demo/page so the persisted language was set back to "fr". 397 | // Now we can redirect to the URL without language prefix, if default prefixes are disabled. 398 | $reset = !$this->enableDefaultLanguageUrlCode && $language === $this->_defaultLanguage; 399 | 400 | if ($reset || $normalized) { 401 | $this->redirectToLanguage(''); 402 | } 403 | } else { 404 | $language = null; 405 | if ($this->enableLanguagePersistence) { 406 | $language = $this->loadPersistedLanguage(); 407 | } 408 | if ($language === null) { 409 | $language = $this->detectLanguage(); 410 | } 411 | if ($language === null || $language === $this->_defaultLanguage) { 412 | if (!$this->enableDefaultLanguageUrlCode) { 413 | return; 414 | } else { 415 | $language = $this->_defaultLanguage; 416 | } 417 | } 418 | // #35: Only redirect if a valid language was found 419 | if ($this->matchCode($language) === [null, null]) { 420 | return; 421 | } 422 | 423 | $key = array_search($language, $this->languages); 424 | if ($key && is_string($key)) { 425 | $language = $key; 426 | } 427 | if (!$this->keepUppercaseLanguageCode) { 428 | $language = strtolower($language); 429 | } 430 | $this->redirectToLanguage($language); 431 | } 432 | } 433 | 434 | /** 435 | * @param string $language the language code to persist in session and cookie 436 | */ 437 | protected function persistLanguage($language) 438 | { 439 | if ($this->hasEventHandlers(self::EVENT_LANGUAGE_CHANGED)) { 440 | $oldLanguage = $this->loadPersistedLanguage(); 441 | if ($oldLanguage !== $language) { 442 | Yii::trace("Triggering languageChanged event: $oldLanguage -> $language", __METHOD__); 443 | $this->trigger(self::EVENT_LANGUAGE_CHANGED, new LanguageChangedEvent([ 444 | 'oldLanguage' => $oldLanguage, 445 | 'language' => $language, 446 | ])); 447 | } 448 | } 449 | if ($this->languageSessionKey !== false) { 450 | Yii::$app->session[$this->languageSessionKey] = $language; 451 | Yii::trace("Persisting language '$language' in session.", __METHOD__); 452 | } 453 | if ($this->languageCookieDuration) { 454 | $cookie = new Cookie(array_merge( 455 | ['httpOnly' => true], 456 | $this->languageCookieOptions, 457 | [ 458 | 'name' => $this->languageCookieName, 459 | 'value' => $language, 460 | 'expire' => time() + (int) $this->languageCookieDuration, 461 | ] 462 | )); 463 | Yii::$app->getResponse()->getCookies()->add($cookie); 464 | Yii::trace("Persisting language '$language' in cookie.", __METHOD__); 465 | } 466 | } 467 | 468 | /** 469 | * @return string|null the persisted language code or null if none found 470 | */ 471 | protected function loadPersistedLanguage() 472 | { 473 | $language = null; 474 | if ($this->languageSessionKey !== false) { 475 | $language = Yii::$app->session->get($this->languageSessionKey); 476 | $language!==null && Yii::trace("Found persisted language '$language' in session.", __METHOD__); 477 | } 478 | if ($language === null) { 479 | $language = $this->_request->getCookies()->getValue($this->languageCookieName); 480 | $language!==null && Yii::trace("Found persisted language '$language' in cookie.", __METHOD__); 481 | } 482 | return $language; 483 | } 484 | 485 | /** 486 | * @return string|null the language detected from request headers or via 487 | * GeoIp module 488 | */ 489 | protected function detectLanguage() 490 | { 491 | if ($this->enableLanguageDetection) { 492 | foreach ($this->_request->getAcceptableLanguages() as $acceptable) { 493 | list($language,$country) = $this->matchCode($acceptable); 494 | if ($language!==null) { 495 | $language = $country === null ? $language : "$language-$country"; 496 | Yii::trace("Detected browser language '$language'.", __METHOD__); 497 | return $language; 498 | } 499 | } 500 | } 501 | if (isset($_SERVER[$this->geoIpServerVar])) { 502 | foreach ($this->geoIpLanguageCountries as $key => $codes) { 503 | $country = $_SERVER[$this->geoIpServerVar]; 504 | if (in_array($country, $codes)) { 505 | Yii::trace("Detected GeoIp language '$key'.", __METHOD__); 506 | return $key; 507 | } 508 | } 509 | } 510 | } 511 | 512 | /** 513 | * Tests whether the given code matches any of the configured languages. 514 | * 515 | * The return value is an array of the form `[$language, $country]`, where 516 | * `$country` or both can be `null`. 517 | * 518 | * If the code is a single language code, and matches either 519 | * 520 | * - an exact language as configured (ll) 521 | * - a language with a country wildcard (ll-*) 522 | * 523 | * the code value will be returned as `$language`. 524 | * 525 | * If the code is of the form `ll-CC`, and matches either 526 | * 527 | * - an exact language/country code as configured (ll-CC) 528 | * - a language with a country wildcard (ll-*) 529 | * 530 | * `$country` well be set to the `CC` part of the configured language. 531 | * If only the language part matches a configured language, only `$language` 532 | * will be set to that language. 533 | * 534 | * @param string $code the code to match 535 | * @return array of `[$language, $country]` where `$country` or both can be 536 | * `null` 537 | */ 538 | protected function matchCode($code) 539 | { 540 | $hasDash = strpos($code, '-') !== false; 541 | $lcCode = strtolower($code); 542 | $lcLanguages = array_map('strtolower', $this->languages); 543 | 544 | if (($key = array_search($lcCode, $lcLanguages)) === false) { 545 | if ($hasDash) { 546 | list($language, $country) = explode('-', $code, 2); 547 | } else { 548 | $language = $code; 549 | $country = null; 550 | } 551 | if (in_array($language . '-*', $this->languages)) { 552 | if ($hasDash) { 553 | // TODO: Make wildcards work with script codes 554 | // like `sr-Latn` 555 | return [$language, strtoupper($country)]; 556 | } else { 557 | return [$language, null]; 558 | } 559 | } elseif ($hasDash && in_array($language, $this->languages)) { 560 | return [$language, null]; 561 | } else { 562 | return [null, null]; 563 | } 564 | } else { 565 | $result = $this->languages[$key]; 566 | return $hasDash ? explode('-', $result, 2) : [$result, null]; 567 | } 568 | 569 | $language = $code; 570 | $country = null; 571 | $parts = explode('-', $code); 572 | if (count($parts) === 2) { 573 | $language = $parts[0]; 574 | $country = strtoupper($parts[1]); 575 | } 576 | 577 | if (in_array($code, $this->languages)) { 578 | return [$language, $country]; 579 | } elseif ( 580 | $country && in_array("$language-$country", $this->languages) || 581 | in_array("$language-*", $this->languages) 582 | ) { 583 | return [$language, $country]; 584 | } elseif (in_array($language, $this->languages)) { 585 | return [$language, null]; 586 | } else { 587 | return [null, null]; 588 | } 589 | } 590 | 591 | /** 592 | * Redirect to the current URL with given language code applied 593 | * 594 | * @param string $language the language code to add. Can also be empty to 595 | * not add any language code. 596 | * @throws \yii\base\Exception 597 | * @throws \yii\web\NotFoundHttpException 598 | */ 599 | protected function redirectToLanguage($language) 600 | { 601 | try { 602 | $result = parent::parseRequest($this->_request); 603 | } catch (UrlNormalizerRedirectException $e) { 604 | if (is_array($e->url)) { 605 | $params = $e->url; 606 | $route = array_shift($params); 607 | $result = [$route, $params]; 608 | } else { 609 | $result = [$e->url, []]; 610 | } 611 | } 612 | if ($result === false) { 613 | throw new \yii\web\NotFoundHttpException(Yii::t('yii', 'Page not found.')); 614 | } 615 | list ($route, $params) = $result; 616 | if($language){ 617 | $params[$this->languageParam] = $language; 618 | } 619 | // See Yii Issues #8291 and #9161: 620 | $params = [$route] + $params + $this->_request->getQueryParams(); 621 | $url = $this->createUrl($params); 622 | // Required to prevent double slashes on generated URLs 623 | if ($this->suffix === '/' && $route === '' && count($params) === 1) { 624 | $url = rtrim($url, '/').'/'; 625 | } 626 | // Prevent redirects to same URL which could happen in certain 627 | // UrlNormalizer / custom rule combinations 628 | if ($url === $this->_request->url) { 629 | return; 630 | } 631 | Yii::trace("Redirecting to $url.", __METHOD__); 632 | Yii::$app->getResponse()->redirect($url, $this->languageRedirectCode); 633 | if (YII2_LOCALEURLS_TEST) { 634 | // Response::redirect($url) above will call `Url::to()` internally. 635 | // So to really test for the same final redirect URL here, we need 636 | // to call Url::to(), too. 637 | throw new \yii\base\Exception(\yii\helpers\Url::to($url)); 638 | } else { 639 | Yii::$app->end(); 640 | } 641 | } 642 | } 643 | --------------------------------------------------------------------------------