├── src ├── Middleware │ ├── Detectors │ │ ├── Detector.php │ │ ├── AppDetector.php │ │ ├── OmittedLocaleDetector.php │ │ ├── SessionDetector.php │ │ ├── BrowserDetector.php │ │ ├── CookieDetector.php │ │ ├── UserDetector.php │ │ ├── RouteActionDetector.php │ │ └── UrlDetector.php │ ├── Stores │ │ ├── Store.php │ │ ├── AppStore.php │ │ ├── SessionStore.php │ │ └── CookieStore.php │ ├── SetLocale.php │ └── LocaleHandler.php ├── ProvidesRouteParameters.php ├── helpers.php ├── Facades │ └── LocaleConfig.php ├── Macros │ └── Route │ │ ├── IsFallbackMacro.php │ │ ├── IsLocalizedMacro.php │ │ ├── LocalizedMacro.php │ │ ├── HasLocalizedMacro.php │ │ └── LocalizedUrlMacro.php ├── Illuminate │ └── Routing │ │ ├── Redirector.php │ │ └── UrlGenerator.php ├── Controllers │ └── FallbackController.php ├── RouteHelper.php ├── LocalizedRoutesRegistrar.php ├── LocaleConfig.php ├── LocalizedRoutesServiceProvider.php └── LocalizedUrlGenerator.php ├── .windsurfrules ├── LICENSE.md ├── composer.json ├── config └── localized-routes.php ├── UPGRADE.md └── README.md /src/Middleware/Detectors/Detector.php: -------------------------------------------------------------------------------- 1 | route($name, $parameters, $absolute, $locale); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Facades/LocaleConfig.php: -------------------------------------------------------------------------------- 1 | filter(new CombinedFilter); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Macros/Route/IsFallbackMacro.php: -------------------------------------------------------------------------------- 1 | isFallback(); 20 | }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Middleware/Stores/SessionStore.php: -------------------------------------------------------------------------------- 1 | isLocalized($patterns, $locales); 20 | }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Macros/Route/LocalizedMacro.php: -------------------------------------------------------------------------------- 1 | register($closure, $options); 20 | }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Macros/Route/HasLocalizedMacro.php: -------------------------------------------------------------------------------- 1 | hasLocalized($name, $locale); 20 | }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Middleware/Stores/CookieStore.php: -------------------------------------------------------------------------------- 1 | getAttributeValue($attribute); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Macros/Route/LocalizedUrlMacro.php: -------------------------------------------------------------------------------- 1 | generateFromRequest($locale, $parameters, $absolute, $keepQuery); 20 | }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.windsurfrules: -------------------------------------------------------------------------------- 1 | # Laravel Localized Routes - Claude Guide 2 | 3 | ## Commands 4 | - Run all tests: `composer test` 5 | - Run single test: `./vendor/bin/phpunit --filter=TestName` 6 | - Run test suite: `./vendor/bin/phpunit --testsuite=Unit` or `--testsuite=Feature` 7 | 8 | ## Code Style 9 | - PSR-4 autoloading standard with `CodeZero\LocalizedRoutes\` namespace 10 | - Method visibility: public methods first, then protected, then private 11 | - DocBlocks for all methods with `@return` types 12 | - Type hints for method parameters and return types where possible 13 | - Protected properties with descriptive names 14 | - Explicit imports (no group imports) 15 | - Laravel naming conventions for classes and methods 16 | - Error handling through exceptions with descriptive messages 17 | 18 | ## Development 19 | - PHP 8.1+ required 20 | - Laravel 10+ compatibility maintained 21 | - Tests cover all functionality 22 | - Follows semantic versioning 23 | -------------------------------------------------------------------------------- /src/Middleware/Detectors/RouteActionDetector.php: -------------------------------------------------------------------------------- 1 | route = $request->route(); 25 | } 26 | 27 | /** 28 | * Detect the locale. 29 | * 30 | * @return string|array|null 31 | */ 32 | public function detect() 33 | { 34 | $action = Config::get('localized-routes.route_action'); 35 | 36 | return $this->route->getAction($action); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Middleware/SetLocale.php: -------------------------------------------------------------------------------- 1 | handler = $handler; 24 | } 25 | 26 | /** 27 | * Handle an incoming request. 28 | * 29 | * @param \Illuminate\Http\Request $request 30 | * @param \Closure $next 31 | * 32 | * @return mixed 33 | */ 34 | public function handle($request, Closure $next) 35 | { 36 | $locale = $this->handler->detect(); 37 | 38 | if ($locale) { 39 | $this->handler->store($locale); 40 | } 41 | 42 | return $next($request); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) Ivan Vermeyen () 4 | 5 | > Permission is hereby granted, free of charge, to any person obtaining a copy 6 | > of this software and associated documentation files (the "Software"), to deal 7 | > in the Software without restriction, including without limitation the rights 8 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | > copies of the Software, and to permit persons to whom the Software is 10 | > furnished to do so, subject to the following conditions: 11 | > 12 | > The above copyright notice and this permission notice shall be included in 13 | > all copies or substantial portions of the Software. 14 | > 15 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | > THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Middleware/Detectors/UrlDetector.php: -------------------------------------------------------------------------------- 1 | request = $request; 25 | } 26 | 27 | /** 28 | * Detect the locale. 29 | * 30 | * @return string|array|null 31 | */ 32 | public function detect() 33 | { 34 | $slug = $this->request->segment(1); 35 | 36 | // If supported locales is a simple array like ['en', 'nl'] 37 | // just return the slug and let the calling code check if it is supported. 38 | if ( ! LocaleConfig::hasLocales() || LocaleConfig::hasSimpleLocales()) { 39 | return $slug; 40 | } 41 | 42 | // Find the locale that belongs to the custom domain or slug. 43 | // Return the original slug as fallback. 44 | // The calling code should validate and handle it. 45 | $domain = $this->request->getHttpHost(); 46 | $locale = LocaleConfig::findLocaleByDomain($domain) ?? LocaleConfig::findLocaleBySlug($slug) ?? $slug; 47 | 48 | return $locale; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opgginc/codezero-laravel-localized-routes", 3 | "description": "A convenient way to set up, manage and use localized routes in a Laravel app.", 4 | "keywords": [ 5 | "php", 6 | "laravel", 7 | "localization", 8 | "locale", 9 | "language", 10 | "country", 11 | "routes", 12 | "routing", 13 | "translation" 14 | ], 15 | "license": "MIT", 16 | "authors": [ 17 | { 18 | "name": "Ivan Vermeyen", 19 | "email": "ivan@codezero.be" 20 | } 21 | ], 22 | "require": { 23 | "php": "^8.1|^8.2|^8.3", 24 | "codezero/browser-locale": "^3.0", 25 | "codezero/composer-preload-files": "^1.0", 26 | "opgginc/codezero-laravel-uri-translator": "^2.0", 27 | "codezero/php-url-builder": "^1.0", 28 | "illuminate/support": "^10.0|^11.0|^12.0" 29 | }, 30 | "require-dev": { 31 | "mockery/mockery": "^1.3.3", 32 | "orchestra/testbench": "^8.0|^9.0|^10.0", 33 | "phpunit/phpunit": "^10.5|^11.0" 34 | }, 35 | "scripts": { 36 | "test": "phpunit" 37 | }, 38 | "autoload": { 39 | "psr-4": { 40 | "CodeZero\\LocalizedRoutes\\": "src" 41 | } 42 | }, 43 | "autoload-dev": { 44 | "psr-4": { 45 | "CodeZero\\LocalizedRoutes\\Tests\\": "tests" 46 | } 47 | }, 48 | "extra": { 49 | "laravel": { 50 | "providers": [ 51 | "CodeZero\\LocalizedRoutes\\LocalizedRoutesServiceProvider" 52 | ], 53 | "aliases": { 54 | "LocaleConfig": "CodeZero\\LocalizedRoutes\\Facades\\LocaleConfig" 55 | } 56 | }, 57 | "preload-files": [ 58 | "src/helpers.php" 59 | ] 60 | }, 61 | "config": { 62 | "preferred-install": "dist", 63 | "sort-packages": true, 64 | "optimize-autoloader": true, 65 | "allow-plugins": { 66 | "codezero/composer-preload-files": true 67 | } 68 | }, 69 | "minimum-stability": "dev", 70 | "prefer-stable": true 71 | } 72 | -------------------------------------------------------------------------------- /src/Illuminate/Routing/Redirector.php: -------------------------------------------------------------------------------- 1 | to($this->generator->route($route, $parameters, true, $locale), $status, $headers); 23 | } 24 | 25 | /** 26 | * Create a new redirect response to a signed named route. 27 | * 28 | * @param string $route 29 | * @param mixed $parameters 30 | * @param \DateTimeInterface|\DateInterval|int|null $expiration 31 | * @param int $status 32 | * @param array $headers 33 | * @param string|null $locale 34 | * 35 | * @return \Illuminate\Http\RedirectResponse 36 | */ 37 | public function signedRoute($route, $parameters = [], $expiration = null, $status = 302, $headers = [], $locale = null) 38 | { 39 | return $this->to($this->generator->signedRoute($route, $parameters, $expiration, true, $locale), $status, $headers); 40 | } 41 | 42 | /** 43 | * Create a new redirect response to a signed named route. 44 | * 45 | * @param string $route 46 | * @param \DateTimeInterface|\DateInterval|int|null $expiration 47 | * @param mixed $parameters 48 | * @param int $status 49 | * @param array $headers 50 | * @param string|null $locale 51 | * 52 | * @return \Illuminate\Http\RedirectResponse 53 | */ 54 | public function temporarySignedRoute($route, $expiration, $parameters = [], $status = 302, $headers = [], $locale = null) 55 | { 56 | return $this->to($this->generator->temporarySignedRoute($route, $expiration, $parameters, true, $locale), $status, $headers); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Controllers/FallbackController.php: -------------------------------------------------------------------------------- 1 | redirectResponse() ?: $this->notFoundResponse(); 23 | } 24 | 25 | /** 26 | * Return a redirect response if needed. 27 | * 28 | * @return \Illuminate\Http\RedirectResponse|false 29 | */ 30 | protected function redirectResponse() 31 | { 32 | if ( ! $this->shouldRedirect()) { 33 | return false; 34 | } 35 | 36 | $localizedUrl = Route::localizedUrl(); 37 | $route = $this->findRouteByUrl($localizedUrl); 38 | 39 | if ($route->isFallback) { 40 | return false; 41 | } 42 | 43 | return Redirect::to($localizedUrl, $this->getRedirectStatusCode()) 44 | ->header('Cache-Control', 'no-store, no-cache, must-revalidate') 45 | ->header('Vary', 'Accept-Language'); 46 | } 47 | 48 | /** 49 | * Find a Route by its URL. 50 | * 51 | * @param string $url 52 | * 53 | * @return \Illuminate\Routing\Route 54 | */ 55 | protected function findRouteByUrl(string $url) 56 | { 57 | return Route::getRoutes()->match(Request::create($url)); 58 | } 59 | 60 | /** 61 | * Return a 404 view or throw a 404 error if the view doesn't exist. 62 | * 63 | * @return \Illuminate\Http\Response 64 | */ 65 | protected function notFoundResponse() 66 | { 67 | $view = Config::get('localized-routes.404_view'); 68 | 69 | if (View::exists($view)) { 70 | return Response::view($view, [], 404); 71 | } 72 | 73 | abort(404); 74 | } 75 | 76 | /** 77 | * Determine if we need to redirect to a localized version of this route. 78 | * 79 | * @return bool 80 | */ 81 | protected function shouldRedirect() 82 | { 83 | return Config::get('localized-routes.redirect_to_localized_urls'); 84 | } 85 | 86 | /** 87 | * Get the redirect status code from config. 88 | * 89 | * @return int 90 | */ 91 | protected function getRedirectStatusCode() 92 | { 93 | return Config::get('localized-routes.redirect_status_code', 301); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/RouteHelper.php: -------------------------------------------------------------------------------- 1 | route = $request->route(); 28 | } 29 | 30 | /** 31 | * Check if the current route is a fallback route. 32 | * 33 | * @return bool 34 | */ 35 | public function isFallback(): bool 36 | { 37 | return $this->route && $this->route->isFallback; 38 | } 39 | 40 | /** 41 | * Check if the current route is localized. 42 | * 43 | * @param string|array $patterns 44 | * @param string|array $locales 45 | * 46 | * @return bool 47 | */ 48 | public function isLocalized($patterns = null, $locales = '*'): bool 49 | { 50 | return $patterns === null 51 | ? $this->isCurrentRouteLocalized() 52 | : $this->isCurrentRouteLocalizedWithNamePattern($patterns, $locales); 53 | } 54 | 55 | /** 56 | * Check if a localized route exists. 57 | * 58 | * @param string $name 59 | * @param string|null $locale 60 | * 61 | * @return bool 62 | */ 63 | public function hasLocalized(string $name, ?string $locale = null): bool 64 | { 65 | $locale = $locale ?: App::getLocale(); 66 | 67 | return Route::has("{$locale}.{$name}"); 68 | } 69 | 70 | /** 71 | * Check if the current route is localized. 72 | * 73 | * @return bool 74 | */ 75 | protected function isCurrentRouteLocalized(): bool 76 | { 77 | $routeAction = LocaleConfig::getRouteAction(); 78 | 79 | return $this->route && $this->route->getAction($routeAction) !== null; 80 | } 81 | 82 | /** 83 | * Check if the current route is localized and has a specific name. 84 | * 85 | * @param string|array $patterns 86 | * @param string|array $locales 87 | * 88 | * @return bool 89 | */ 90 | protected function isCurrentRouteLocalizedWithNamePattern($patterns = null, $locales = '*'): bool 91 | { 92 | $locales = Collection::make($locales); 93 | $names = Collection::make(); 94 | 95 | Collection::make($patterns)->each(function ($name) use ($locales, $names) { 96 | $locales->each(function ($locale) use ($name, $names) { 97 | $names->push($locale . '.' . $name); 98 | }); 99 | }); 100 | 101 | return Route::is($names->all()); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Middleware/LocaleHandler.php: -------------------------------------------------------------------------------- 1 | locales = $locales; 48 | $this->detectors = $detectors; 49 | $this->stores = $stores; 50 | $this->trustedDetectors = $trustedDetectors; 51 | } 52 | 53 | /** 54 | * Detect any supported locale and return the first match. 55 | * 56 | * @return string|null 57 | */ 58 | public function detect(): ?string 59 | { 60 | foreach ($this->detectors as $detector) { 61 | $locales = (array) $this->getInstance($detector)->detect(); 62 | 63 | foreach ($locales as $locale) { 64 | if ($locale && ($this->isSupportedLocale($locale) || $this->isTrustedDetector($detector))) { 65 | return $locale; 66 | } 67 | } 68 | } 69 | 70 | return null; 71 | } 72 | 73 | /** 74 | * Store the given locale. 75 | * 76 | * @param string $locale 77 | * 78 | * @return void 79 | */ 80 | public function store(string $locale): void 81 | { 82 | foreach ($this->stores as $store) { 83 | $this->getInstance($store)->store($locale); 84 | } 85 | } 86 | 87 | /** 88 | * Check if the given locale is supported. 89 | * 90 | * @param string|null $locale 91 | * 92 | * @return bool 93 | */ 94 | protected function isSupportedLocale(?string $locale): bool 95 | { 96 | return in_array($locale, $this->locales); 97 | } 98 | 99 | /** 100 | * Check if the given Detector class is trusted. 101 | * 102 | * @param \CodeZero\LocalizedRoutes\Middleware\Detectors\Detector|string $detector 103 | * 104 | * @return bool 105 | */ 106 | protected function isTrustedDetector($detector): bool 107 | { 108 | if (is_string($detector)) { 109 | return in_array($detector, $this->trustedDetectors); 110 | } 111 | 112 | foreach ($this->trustedDetectors as $trustedDetector) { 113 | if ($detector instanceof $trustedDetector) { 114 | return true; 115 | } 116 | } 117 | 118 | return false; 119 | } 120 | 121 | /** 122 | * Get the class from Laravel's IOC container if it is a string. 123 | * 124 | * @param mixed $class 125 | * 126 | * @return mixed 127 | */ 128 | protected function getInstance($class) 129 | { 130 | if (is_string($class)) { 131 | return App::make($class); 132 | } 133 | 134 | return $class; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /config/localized-routes.php: -------------------------------------------------------------------------------- 1 | [], 9 | 10 | /** 11 | * The fallback locale to use when generating a route URL 12 | * and the provided locale is not supported. 13 | */ 14 | 'fallback_locale' => null, 15 | 16 | /** 17 | * If you have a main locale, and you want to omit 18 | * its slug from the URL, specify it here. 19 | */ 20 | 'omitted_locale' => null, 21 | 22 | /** 23 | * Set this option to true if you want to redirect URLs 24 | * without a locale slug to their localized version. 25 | * You need to register the fallback route for this to work. 26 | */ 27 | 'redirect_to_localized_urls' => false, 28 | 29 | /** 30 | * The status code when redirecting to localized URLs. 31 | * 301 - permanently 32 | * 302 - temporary 33 | */ 34 | 'redirect_status_code' => 301, 35 | 36 | /** 37 | * Set your custom 404 view. This view is localized. 38 | * If the view does not exist, a normal 404 will be thrown. 39 | * You need to register the fallback route for this to work. 40 | */ 41 | '404_view' => 'errors.404', 42 | 43 | /** 44 | * The custom route action where we will set the locale of the routes 45 | * that are registered within the Route::localized() closure. 46 | * This can be detected by the RouteActionDetector. 47 | */ 48 | 'route_action' => 'locale', 49 | 50 | /** 51 | * The attribute on the user model that holds the locale, 52 | * when using the UserDetector. 53 | */ 54 | 'user_attribute' => 'locale', 55 | 56 | /** 57 | * The session key that holds the locale, 58 | * when using the SessionDetector and SessionStore. 59 | */ 60 | 'session_key' => 'locale', 61 | 62 | /** 63 | * The name of the cookie that holds the locale, 64 | * when using the CookieDetector and CookieStore. 65 | */ 66 | 'cookie_name' => 'locale', 67 | 68 | /** 69 | * The lifetime of the cookie that holds the locale, 70 | * when using the CookieStore. 71 | */ 72 | 'cookie_minutes' => 60 * 24 * 365, // 1 year 73 | 74 | /** 75 | * Check raw cookie with $_COOKIE due to the default encryption by Laravel. 76 | */ 77 | 'check_raw_cookie' => false, 78 | 79 | /** 80 | * The detectors to use to find a matching locale. 81 | * These will be executed in the order that they are added to the array! 82 | */ 83 | 'detectors' => [ 84 | CodeZero\LocalizedRoutes\Middleware\Detectors\RouteActionDetector::class, //=> required for scoped config 85 | CodeZero\LocalizedRoutes\Middleware\Detectors\UrlDetector::class, //=> required 86 | CodeZero\LocalizedRoutes\Middleware\Detectors\OmittedLocaleDetector::class, //=> required for omitted locale 87 | CodeZero\LocalizedRoutes\Middleware\Detectors\UserDetector::class, 88 | CodeZero\LocalizedRoutes\Middleware\Detectors\SessionDetector::class, 89 | CodeZero\LocalizedRoutes\Middleware\Detectors\CookieDetector::class, 90 | CodeZero\LocalizedRoutes\Middleware\Detectors\BrowserDetector::class, 91 | CodeZero\LocalizedRoutes\Middleware\Detectors\AppDetector::class, //=> required 92 | ], 93 | 94 | /** 95 | * Add any of the above detector class names here to make it trusted. 96 | * When a trusted detector returns a locale, it will be used 97 | * as the app locale, regardless if it's a supported locale or not. 98 | */ 99 | 'trusted_detectors' => [ 100 | CodeZero\LocalizedRoutes\Middleware\Detectors\RouteActionDetector::class //=> required for scoped config 101 | ], 102 | 103 | /** 104 | * The stores to store the first matching locale in. 105 | */ 106 | 'stores' => [ 107 | CodeZero\LocalizedRoutes\Middleware\Stores\SessionStore::class, 108 | CodeZero\LocalizedRoutes\Middleware\Stores\CookieStore::class, 109 | CodeZero\LocalizedRoutes\Middleware\Stores\AppStore::class, //=> required 110 | ], 111 | 112 | ]; 113 | -------------------------------------------------------------------------------- /src/LocalizedRoutesRegistrar.php: -------------------------------------------------------------------------------- 1 | moveOmittedLocaleToEnd($locales, $omittedLocale, $usingDomains, $usingCustomSlugs); 36 | 37 | // Remember the current locale, so we can 38 | // change it during route registration. 39 | $currentLocale = App::getLocale(); 40 | 41 | foreach ($locales as $locale => $domainOrSlug) { 42 | // If the locale key is numeric, we have a simple array of locales. 43 | // In this case, the locale is the same as the slug. 44 | if ( ! $usingDomains && ! $usingCustomSlugs) { 45 | $locale = $domainOrSlug; 46 | } 47 | 48 | // Prepend the locale to the route names and set 49 | // a custom route action, so the middleware can 50 | // find it to set the correct app locale. 51 | $attributes = [ 52 | 'as' => "{$locale}.", 53 | $localeRouteAction => $locale, 54 | ]; 55 | 56 | // Add a custom domain to the route group 57 | // when custom domains are configured. 58 | if ($usingDomains) { 59 | $attributes['domain'] = $domainOrSlug; 60 | } 61 | 62 | // Add a URL prefix to the route group, unless 63 | // the locale is configured to be omitted. 64 | if ( ! $usingDomains && $locale !== $omittedLocale) { 65 | $attributes['prefix'] = $domainOrSlug; 66 | } 67 | 68 | // Temporarily change the active locale, so any 69 | // translations made in the routes closure are 70 | // automatically in the correct language. 71 | App::setLocale($locale); 72 | 73 | // Register the route group. 74 | Route::group($attributes, $closure); 75 | } 76 | 77 | // Restore the original locale. 78 | App::setLocale($currentLocale); 79 | } 80 | 81 | /** 82 | * Move the omitted locale to the end of the locales array. 83 | * 84 | * @param array $locales 85 | * @param string|null $omittedLocale 86 | * @param bool $usingDomains 87 | * @param bool $usingCustomSlugs 88 | * 89 | * @return array 90 | */ 91 | protected function moveOmittedLocaleToEnd(array $locales, ?string $omittedLocale, bool $usingDomains, bool $usingCustomSlugs): array 92 | { 93 | if ( ! $omittedLocale || $usingDomains) { 94 | return $locales; 95 | } 96 | 97 | // When using custom slugs, the locales are the array keys. 98 | // Remove the omitted locale from the array 99 | // and add it back on to the end. 100 | if ($usingCustomSlugs) { 101 | $omitSlug = $locales[$omittedLocale]; 102 | unset($locales[$omittedLocale]); 103 | $locales[$omittedLocale] = $omitSlug; 104 | 105 | return $locales; 106 | } 107 | 108 | // When not using custom slugs or domains, the array keys are numeric. 109 | // Filter out the omitted locale and then add it back to the end. 110 | $locales = array_filter($locales, function ($locale) use ($omittedLocale) { 111 | return $locale !== $omittedLocale; 112 | }); 113 | 114 | $locales[] = $omittedLocale; 115 | 116 | return $locales; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Illuminate/Routing/UrlGenerator.php: -------------------------------------------------------------------------------- 1 | resolveLocalizedRouteName($name, $locale, $currentLocale); 29 | 30 | // Update the current locale if needed. 31 | if ($locale !== null && $locale !== $currentLocale) { 32 | App::setLocale($locale); 33 | } 34 | 35 | try { 36 | $url = parent::route($resolvedName, $parameters, $absolute); 37 | } finally { 38 | // Restore the current locale if needed. 39 | if ($locale !== null && $locale !== $currentLocale) { 40 | App::setLocale($currentLocale); 41 | } 42 | } 43 | 44 | return $url; 45 | } 46 | 47 | /** 48 | * Create a signed route URL for a named route. 49 | * 50 | * @param string $name 51 | * @param mixed $parameters 52 | * @param \DateInterval|\DateTimeInterface|int|null $expiration 53 | * @param bool $absolute 54 | * @param string|null $locale 55 | * 56 | * @return string 57 | */ 58 | public function signedRoute($name, $parameters = [], $expiration = null, $absolute = true, $locale = null) 59 | { 60 | // Cache the current locale, so we can change it to automatically 61 | // resolve any translatable route parameters such as slugs. 62 | $currentLocale = App::getLocale(); 63 | 64 | $resolvedName = $this->resolveLocalizedRouteName($name, $locale, $currentLocale); 65 | 66 | // Update the current locale if needed. 67 | if ($locale !== null && $locale !== $currentLocale) { 68 | App::setLocale($locale); 69 | } 70 | 71 | try { 72 | $url = parent::signedRoute($resolvedName, $parameters, $expiration, $absolute); 73 | } finally { 74 | // Restore the current locale if needed. 75 | if ($locale !== null && $locale !== $currentLocale) { 76 | App::setLocale($currentLocale); 77 | } 78 | } 79 | 80 | return $url; 81 | } 82 | 83 | /** 84 | * Create a temporary signed route URL for a named route. 85 | * 86 | * @param string $name 87 | * @param \DateTimeInterface|\DateInterval|int $expiration 88 | * @param array $parameters 89 | * @param bool $absolute 90 | * @param string|null $locale 91 | * 92 | * @return string 93 | */ 94 | public function temporarySignedRoute($name, $expiration, $parameters = [], $absolute = true, $locale = null) 95 | { 96 | return $this->signedRoute($name, $parameters, $expiration, $absolute, $locale); 97 | } 98 | 99 | /** 100 | * Resolve a localized version of the route name in the given locale. 101 | * 102 | * @param string $name 103 | * @param string|null $locale 104 | * @param string $currentLocale 105 | * 106 | * @return string 107 | */ 108 | protected function resolveLocalizedRouteName($name, $locale, $currentLocale) 109 | { 110 | // If the route exists, and we're not requesting a specific locale, 111 | // let the base class resolve the route. 112 | if (Route::has($name) && $locale === null) { 113 | return $name; 114 | } 115 | 116 | // Normalize the route name by removing any locale prefix. 117 | // We will prepend the applicable locale manually. 118 | $baseName = $this->stripLocaleFromRouteName($name); 119 | 120 | if ($baseName === '') { 121 | return ''; 122 | } 123 | 124 | // Use the specified or current locale 125 | // as a prefix for the route name. 126 | $locale = $locale ?: $currentLocale; 127 | $newName = "{$locale}.{$baseName}"; 128 | $fallbackLocale = LocaleConfig::getFallbackLocale(); 129 | 130 | // If the localized route name doesn't exist, 131 | // use a fallback locale if one is configured. 132 | if ( ! Route::has($newName) && $fallbackLocale) { 133 | $newName = "{$fallbackLocale}.{$baseName}"; 134 | } 135 | 136 | // If the unprefixed route name exists, but the new localized route name doesn't, 137 | // someone may be trying to resolve a localized name in an unsupported locale, 138 | // e.g. "route('en.route', [], true, 'fr')" (where 'fr.route' doesn't exist and 'route' does) 139 | // In that case, resolve the unprefixed route name. 140 | if (Route::has($baseName) && ! Route::has($newName)) { 141 | $newName = $baseName; 142 | } 143 | 144 | return $newName; 145 | } 146 | 147 | /** 148 | * Strip the locale from the beginning of a route name. 149 | * 150 | * @param string $name 151 | * 152 | * @return string 153 | */ 154 | protected function stripLocaleFromRouteName($name) 155 | { 156 | $parts = explode('.', $name); 157 | 158 | // If there is no dot in the route name, 159 | // there is no locale in the route name. 160 | if (count($parts) === 1) { 161 | return $name; 162 | } 163 | 164 | // If the first part of the route name is a valid 165 | // locale, then remove it from the array. 166 | if (LocaleConfig::isSupportedLocale($parts[0])) { 167 | array_shift($parts); 168 | } 169 | 170 | // Rebuild the normalized route name. 171 | $name = join('.', $parts); 172 | 173 | return $name; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | # Upgrade Guide 2 | 3 | ## Upgrading To 5.0 From 4.x 4 | 5 | ### ➡ Package Name Changed 6 | 7 | The package name has been changed from `codezero/laravel-localized-routes` to `opgginc/codezero-laravel-localized-routes`. 8 | 9 | 🔸 **Actions Required** 10 | 11 | - Update the package using Composer: `composer require opgginc/codezero-laravel-localized-routes` 12 | 13 | ### ➡ Added Support for Laravel 12 and PHP 8.4 14 | 15 | This package now supports Laravel 12 and PHP 8.4. 16 | 17 | - The dependency `codezero/laravel-uri-translator` has been replaced with `opgginc/codezero-laravel-uri-translator`. 18 | 19 | 🔸 **Actions Required** 20 | 21 | - If you are directly using `codezero/laravel-uri-translator`, update it to `opgginc/codezero-laravel-uri-translator`. 22 | 23 | --- 24 | 25 | ## Upgrading To 4.0 From 3.x 26 | 27 | ### ➡ Minimum Requirements Updated 28 | 29 | Due to PHP and PHPUnit version constraints with Laravel 11+, we dropped support for Laravel 7.x, 8.x and 9.x. 30 | 31 | - The minimum PHP version required is now 8.1 (and supports PHP 8.4) 32 | - The minimum Laravel version required is now 10 (and supports Laravel 12) 33 | 34 | --- 35 | 36 | ### ➡ Re-register Middleware 37 | 38 | Laravel 11 no longer has a `app/Http/Kernel.php` to register middleware. 39 | This is now handled in `bootstrap/app.php`. 40 | 41 | 🔸 **Actions Required** 42 | 43 | If you use Laravel 11 or 12, register the middleware in `bootstrap/app.php` as described in the README. 44 | 45 | ## Upgrading To 3.0 From 2.x 46 | 47 | This upgrade contains a number of small but breaking changes, as well as a huge internal makeover. 48 | I listed the most important ones below. 49 | But if you were overriding internal classes or methods, you will need to review the new source in more detail. 50 | 51 | Over the years, this package has grown greatly in features and the codebase was getting a bit out of hand. 52 | Therefor, I spent a lot of time refactoring every bit of code to make it more obvious what is going on, making it easier to maintain in the future. 53 | 54 | I spent possibly even longer rewriting the README. 55 | Hopefully, this is now a 1000 times better structured and easier to digest. 56 | 57 | If you have any problems or improvements, you are always welcome to create an issue or pull request. 58 | 59 | --- 60 | 61 | ### ➡ Minimum Requirements Updated 62 | 63 | We dropped support for Laravel 5.6, 5.7, 5.8 and 6.x. 64 | 65 | - The minimum PHP version required is now 7.2.5 66 | - The minimum Laravel version required is now 7.0 67 | 68 | --- 69 | 70 | ### ➡ Middleware Changes 71 | 72 | Applying the `CodeZero\LocalizedRoutes\Middleware\SetLocale` middleware is now more straightforward. 73 | 74 | The middleware is no longer automatically applied to localized routes if the `use_locale_middleware` option is set to `true`. 75 | 76 | If you choose to use the middleware, you need to apply it manually to your routes. 77 | 78 | 🔸 **Actions Required** 79 | 80 | - Remove the `use_locale_middleware` option from your published `config/localized-routes.php` config file. 81 | - Remove the `use_localizer` option from your published `config/localized-routes.php` config file. 82 | - Make sure you apply the middleware to your routes manually, either on specific routes or route groups, or by adding it to the `web` middleware group in `app/Http/Kernel.php`. 83 | - Make sure you also add the middleware to the `$middlewarePriority` array in `app/Http/Kernel.php` in the correct spot. 84 | 85 | ```php 86 | protected $middlewarePriority = [ 87 | \Illuminate\Session\Middleware\StartSession::class, // <= after this 88 | //... 89 | \CodeZero\LocalizedRoutes\Middleware\SetLocale::class, 90 | \Illuminate\Routing\Middleware\SubstituteBindings::class, // <= before this 91 | ]; 92 | ``` 93 | 94 | --- 95 | 96 | ### ➡ Supported Locales, Slugs and Domains 97 | 98 | The `supported-locales` config option has been renamed to `supported_locales`, using an underscore for consistency. 99 | 100 | The `omit_url_prefix_for_locale` config option has been renamed to `omitted_locale`. 101 | 102 | You can now configure your supported locales in 3 formats. 103 | 104 | ```php 105 | // A simple array; in this case, the locales 106 | // will be used as slugs in the URLs. 107 | 'supported_locales' => ['en', 'nl']; 108 | 109 | // An array with locale / domain pairs, where the locale 110 | // is used for route names etc., and the domain for the URL. 111 | 'supported_locales' => [ 112 | 'en' => 'english-domain.test', 113 | 'nl' => 'dutch-domain.test', 114 | ]; 115 | 116 | // An array with locale / slug pairs, where the locale 117 | // is used for route names etc., and the slug for the URL. 118 | 'supported_locales' => [ 119 | 'en' => 'english-slug', 120 | 'nl' => 'dutch-slug', 121 | ]; 122 | ``` 123 | 124 | 🔸 **Actions Required** 125 | 126 | - Remove the `custom_prefixes` option from your published `config/localized-routes.php` config file. 127 | - Rename the `supported-locales` option to `supported_locales` 128 | - Make sure you configure the `supported_locales` option properly if you are using custom slugs. 129 | - Slugs can not contain dots, because then it is considered a domain. 130 | - Rename the `omit_url_prefix_for_locale` option to `omitted_locale` 131 | 132 | --- 133 | 134 | ### ➡ Custom Route Action Changed 135 | 136 | During route registration, we set the locale on the route using a custom route action. 137 | We changed this route action from `laravel-localized-routes` to simply `locale`. 138 | We also added an option to the config file in case you need to change this name. 139 | 140 | 🔸 **Actions Required** 141 | 142 | - If you are using the `laravel-localized-routes` route action in your own code, you can either update your code with the new `locale` route action, or change it back to `laravel-localized-routes` by setting the `route_action` option in the config file. 143 | 144 | --- 145 | 146 | ### ➡ Changed `FallbackController` Namespace 147 | 148 | The namespace of the `FallbackController` has been pluralized to `CodeZero\LocalizedRoutes\Controllers`. 149 | 150 | 🔸 **Actions Required** 151 | 152 | - If you use the `FallbackController`, update the namespace from `CodeZero\LocalizedRoutes\Controller\FallbackController` to `CodeZero\LocalizedRoutes\Controllers\FallbackController`. 153 | 154 | --- 155 | 156 | ### ➡ Renamed `Route::localizedHas()` Method 157 | 158 | The `Route::localizedHas()` method has been renamed to `Route::hasLocalized()` to be consistent with `Route::isLocalized()`. 159 | 160 | 🔸 **Actions Required** 161 | 162 | - Replace any occurrence of `Route::localizedHas()` with `Route::hasLocalized()`. 163 | -------------------------------------------------------------------------------- /src/LocaleConfig.php: -------------------------------------------------------------------------------- 1 | supportedLocales = $config['supported_locales'] ?? []; 43 | $this->omittedLocale = $config['omitted_locale'] ?? null; 44 | $this->fallbackLocale = $config['fallback_locale'] ?? null; 45 | $this->routeAction = $config['route_action'] ?? null; 46 | } 47 | 48 | /** 49 | * Get the configured supported locales. 50 | * 51 | * @return array 52 | */ 53 | public function getSupportedLocales(): array 54 | { 55 | return $this->supportedLocales; 56 | } 57 | 58 | /** 59 | * Set the supported locales. 60 | * 61 | * @param array $locales 62 | * 63 | * @return void 64 | */ 65 | public function setSupportedLocales(array $locales): void 66 | { 67 | $this->supportedLocales = $locales; 68 | } 69 | 70 | /** 71 | * Get the locale that should be omitted in the URI path. 72 | * 73 | * @return string|null 74 | */ 75 | public function getOmittedLocale(): ?string 76 | { 77 | return $this->omittedLocale; 78 | } 79 | 80 | /** 81 | * Set the locale that should be omitted in the URI path. 82 | * 83 | * @param string|null $locale 84 | * 85 | * @return void 86 | */ 87 | public function setOmittedLocale(?string $locale): void 88 | { 89 | $this->omittedLocale = $locale; 90 | } 91 | 92 | /** 93 | * Get the fallback locale. 94 | * 95 | * @return string|null 96 | */ 97 | public function getFallbackLocale(): ?string 98 | { 99 | return $this->fallbackLocale; 100 | } 101 | 102 | /** 103 | * Set the fallback locale. 104 | * 105 | * @param string|null $locale 106 | * 107 | * @return void 108 | */ 109 | public function setFallbackLocale(?string $locale): void 110 | { 111 | $this->fallbackLocale = $locale; 112 | } 113 | 114 | /** 115 | * Get the route action that holds a route's locale. 116 | * 117 | * @return string|null 118 | */ 119 | public function getRouteAction(): ?string 120 | { 121 | return $this->routeAction; 122 | } 123 | 124 | /** 125 | * Set the route action that holds a route's locale. 126 | * 127 | * @param string $action 128 | * 129 | * @return string 130 | */ 131 | public function setRouteAction(string $action): string 132 | { 133 | return $this->routeAction = $action; 134 | } 135 | 136 | /** 137 | * Get the locales (not the slugs or domains). 138 | * 139 | * @return array 140 | */ 141 | public function getLocales(): array 142 | { 143 | $locales = $this->getSupportedLocales(); 144 | 145 | if ($this->hasSimpleLocales()) { 146 | return $locales; 147 | } 148 | 149 | return array_keys($locales); 150 | } 151 | 152 | /** 153 | * Find the slug that belongs to the given locale. 154 | * 155 | * @param string $locale 156 | * 157 | * @return string|null 158 | */ 159 | public function findSlugByLocale(string $locale): ?string 160 | { 161 | if ( ! $this->isSupportedLocale($locale) || $this->hasCustomDomains()) { 162 | return null; 163 | } 164 | 165 | return $this->getSupportedLocales()[$locale] ?? $locale; 166 | } 167 | 168 | /** 169 | * Find the domain that belongs to the given locale. 170 | * 171 | * @param string $locale 172 | * 173 | * @return string|null 174 | */ 175 | public function findDomainByLocale(string $locale): ?string 176 | { 177 | if ( ! $this->isSupportedLocale($locale) || ! $this->hasCustomDomains()) { 178 | return null; 179 | } 180 | 181 | return $this->getSupportedLocales()[$locale]; 182 | } 183 | 184 | /** 185 | * Find the locale that belongs to the given slug. 186 | * 187 | * @param ?string $slug 188 | * 189 | * @return string|null 190 | */ 191 | public function findLocaleBySlug(?string $slug): ?string 192 | { 193 | if ($this->hasCustomDomains()) { 194 | return null; 195 | } 196 | 197 | if ($this->hasSimpleLocales() && $this->isSupportedLocale($slug)) { 198 | return $slug; 199 | } 200 | 201 | return array_search($slug, $this->getSupportedLocales()) ?: null; 202 | } 203 | 204 | /** 205 | * Find the locale that belongs to the given domain. 206 | * 207 | * @param string $domain 208 | * 209 | * @return string|null 210 | */ 211 | public function findLocaleByDomain(string $domain): ?string 212 | { 213 | if ( ! $this->hasCustomDomains()) { 214 | return null; 215 | } 216 | 217 | return array_search($domain, $this->getSupportedLocales()) ?: null; 218 | } 219 | 220 | /** 221 | * Check if there are any locales configured. 222 | * 223 | * @return bool 224 | */ 225 | public function hasLocales(): bool 226 | { 227 | return count($this->getSupportedLocales()) > 0; 228 | } 229 | 230 | /** 231 | * Check if there are only locales configured, 232 | * and not custom slugs or domains. 233 | * 234 | * @return bool 235 | */ 236 | public function hasSimpleLocales(): bool 237 | { 238 | return is_numeric(key($this->getSupportedLocales())); 239 | } 240 | 241 | /** 242 | * Check if custom slugs are configured. 243 | * 244 | * @return bool 245 | */ 246 | public function hasCustomSlugs(): bool 247 | { 248 | return $this->hasLocales() && ! $this->hasSimpleLocales() && ! $this->hasCustomDomains(); 249 | } 250 | 251 | /** 252 | * Check if custom domains are configured. 253 | * 254 | * @return bool 255 | */ 256 | public function hasCustomDomains(): bool 257 | { 258 | $firstValue = array_values($this->getSupportedLocales())[0] ?? ''; 259 | $containsDot = strpos($firstValue, '.') !== false; 260 | 261 | return $containsDot; 262 | } 263 | 264 | /** 265 | * Check if the given locale is supported. 266 | * 267 | * @param string|null $locale 268 | * 269 | * @return bool 270 | */ 271 | public function isSupportedLocale(?string $locale): bool 272 | { 273 | return in_array($locale, $this->getLocales()); 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /src/LocalizedRoutesServiceProvider.php: -------------------------------------------------------------------------------- 1 | registerPublishableFiles(); 35 | $this->registerMacros(); 36 | } 37 | 38 | /** 39 | * Register any application services. 40 | * 41 | * @return void 42 | */ 43 | public function register() 44 | { 45 | $this->mergeConfig(); 46 | $this->registerLocaleConfig(); 47 | $this->registerLocaleHandler(); 48 | $this->registerUrlGenerator(); 49 | $this->registerRedirector(); 50 | $this->registerProviders(); 51 | } 52 | 53 | /** 54 | * Register macros. 55 | * 56 | * @return void 57 | */ 58 | protected function registerMacros() 59 | { 60 | HasLocalizedMacro::register(); 61 | IsFallbackMacro::register(); 62 | IsLocalizedMacro::register(); 63 | LocalizedMacro::register(); 64 | LocalizedUrlMacro::register(); 65 | } 66 | 67 | /** 68 | * Register the publishable files. 69 | * 70 | * @return void 71 | */ 72 | protected function registerPublishableFiles() 73 | { 74 | $this->publishes([ 75 | __DIR__."/../config/{$this->name}.php" => config_path("{$this->name}.php"), 76 | ], 'config'); 77 | } 78 | 79 | /** 80 | * Merge published configuration file with 81 | * the original package configuration file. 82 | * 83 | * @return void 84 | */ 85 | protected function mergeConfig() 86 | { 87 | $this->mergeConfigFrom(__DIR__."/../config/{$this->name}.php", $this->name); 88 | } 89 | 90 | /** 91 | * Registers the package dependencies 92 | * 93 | * @return void 94 | */ 95 | protected function registerProviders() 96 | { 97 | $this->app->register(BrowserLocaleServiceProvider::class); 98 | $this->app->register(UriTranslatorServiceProvider::class); 99 | } 100 | 101 | /** 102 | * Register the LocaleConfig binding. 103 | * 104 | * @return void 105 | */ 106 | protected function registerLocaleConfig() 107 | { 108 | $this->app->bind(LocaleConfig::class, function ($app) { 109 | return new LocaleConfig($app['config'][$this->name]); 110 | }); 111 | 112 | $this->app->bind('locale-config', LocaleConfig::class); 113 | } 114 | 115 | /** 116 | * Register LocaleHandler. 117 | * 118 | * @return void 119 | */ 120 | protected function registerLocaleHandler() 121 | { 122 | $this->app->bind(LocaleHandler::class, function ($app) { 123 | $locales = $app['locale-config']->getLocales(); 124 | $detectors = $app['config']->get("{$this->name}.detectors"); 125 | $stores = $app['config']->get("{$this->name}.stores"); 126 | $trustedDetectors = $app['config']->get("{$this->name}.trusted_detectors"); 127 | 128 | return new LocaleHandler($locales, $detectors, $stores, $trustedDetectors); 129 | }); 130 | } 131 | 132 | /** 133 | * Register a custom URL generator that extends the one that comes with Laravel. 134 | * This will override a few methods that enables us to generate localized URLs. 135 | * 136 | * This method is an exact copy from: 137 | * \Illuminate\Routing\RoutingServiceProvider 138 | * 139 | * @return void 140 | */ 141 | protected function registerUrlGenerator() 142 | { 143 | $this->app->singleton('url', function ($app) { 144 | $routes = $app['router']->getRoutes(); 145 | 146 | // The URL generator needs the route collection that exists on the router. 147 | // Keep in mind this is an object, so we're passing by references here 148 | // and all the registered routes will be available to the generator. 149 | $app->instance('routes', $routes); 150 | 151 | return new UrlGenerator( 152 | $routes, $app->rebinding( 153 | 'request', $this->requestRebinder() 154 | ), $app['config']['app.asset_url'] 155 | ); 156 | }); 157 | 158 | $this->app->extend('url', function (UrlGeneratorContract $url, $app) { 159 | // Next we will set a few service resolvers on the URL generator so it can 160 | // get the information it needs to function. This just provides some of 161 | // the convenience features to this URL generator like "signed" URLs. 162 | $url->setSessionResolver(function () { 163 | return $this->app['session'] ?? null; 164 | }); 165 | 166 | $url->setKeyResolver(function () { 167 | return $this->app->make('config')->get('app.key'); 168 | }); 169 | 170 | // If the route collection is "rebound", for example, when the routes stay 171 | // cached for the application, we will need to rebind the routes on the 172 | // URL generator instance so it has the latest version of the routes. 173 | $app->rebinding('routes', function ($app, $routes) { 174 | $app['url']->setRoutes($routes); 175 | }); 176 | 177 | return $url; 178 | }); 179 | } 180 | 181 | /** 182 | * Get the URL generator request rebinder. 183 | * 184 | * This method is an exact copy from: 185 | * \Illuminate\Routing\RoutingServiceProvider 186 | * 187 | * @return \Closure 188 | */ 189 | protected function requestRebinder() 190 | { 191 | return function ($app, $request) { 192 | $app['url']->setRequest($request); 193 | }; 194 | } 195 | 196 | /** 197 | * Register a custom URL redirector that extends the one that comes with Laravel. 198 | * This will override a few methods that enables us to redirect to localized URLs. 199 | * 200 | * This method is an exact copy from: 201 | * \Illuminate\Routing\RoutingServiceProvider 202 | * 203 | * @return void 204 | */ 205 | protected function registerRedirector() 206 | { 207 | $this->app->singleton('redirect', function ($app) { 208 | $redirector = new Redirector($app['url']); 209 | 210 | // If the session is set on the application instance, we'll inject it into 211 | // the redirector instance. This allows the redirect responses to allow 212 | // for the quite convenient "with" methods that flash to the session. 213 | if (isset($app['session.store'])) { 214 | $redirector->setSession($app['session.store']); 215 | } 216 | 217 | return $redirector; 218 | }); 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/LocalizedUrlGenerator.php: -------------------------------------------------------------------------------- 1 | request = $request; 38 | $this->route = $request->route(); 39 | } 40 | 41 | /** 42 | * Generate a localized URL for the current request. 43 | * 44 | * @param string|null $locale 45 | * @param mixed $parameters 46 | * @param bool $absolute 47 | * @param bool $keepQuery 48 | * 49 | * @return string 50 | */ 51 | public function generateFromRequest(?string $locale = null, $parameters = null, bool $absolute = true, bool $keepQuery = true): string 52 | { 53 | $urlBuilder = UrlBuilder::make($this->request->fullUrl()); 54 | $requestQueryString = $urlBuilder->getQuery(); 55 | 56 | $currentDomain = $urlBuilder->getHost(); 57 | $currentLocaleSlug = $urlBuilder->getSlugs()[0] ?? null; 58 | 59 | // Determine in which locale the URL needs to be localized. 60 | $locale = $locale 61 | ?? LocaleConfig::findLocaleBySlug($currentLocaleSlug) 62 | ?? LocaleConfig::findLocaleByDomain($currentDomain) 63 | ?? App::getLocale(); 64 | 65 | if ( ! $this->is404()) { 66 | // Use the provided parameter values or get them from the current route. 67 | // Parameters passed to this method may also contain query string parameters. 68 | // $parameters can be an array, a function, or it can contain model instances! 69 | // Parameters fetched from the current route will never contain query string parameters. 70 | // Normalize the parameters, so we end up with an array of key => value pairs. 71 | $normalizedParameters = $this->normalizeParameters($locale, $parameters ?: $this->getRouteParameters()); 72 | 73 | // Get the current route's URI, which has the parameter placeholders. 74 | $routeUri = $this->route->uri(); 75 | 76 | // Separate the route parameters from any query string parameters. 77 | // $routePlaceholders contains "{key}" => "value" pairs. 78 | // $routeParameters contains "key" => "value" pairs. 79 | // $queryStringParameters contains "key" => "value" pairs. 80 | list($routePlaceholders, $routeParameters, $queryStringParameters) = $this->extractRouteAndQueryStringParameters($routeUri, $normalizedParameters); 81 | 82 | $urlBuilder->setQuery( 83 | $this->determineQueryStringParameters($requestQueryString, $queryStringParameters, $keepQuery) 84 | ); 85 | 86 | // Generate the URL using the route's name, if possible. 87 | if ($url = $this->generateNamedRouteURL($locale, $routeParameters, $absolute)) { 88 | return $urlBuilder->getQueryString() ? $url . '?' . $urlBuilder->getQueryString() : $url; 89 | } 90 | 91 | // If a named route could not be resolved, replace the parameter 92 | // placeholders in the URI with their values manually. 93 | $uriWithParameterValues = $this->replaceParameterPlaceholders($routeUri, $routePlaceholders); 94 | $urlBuilder->setPath($uriWithParameterValues); 95 | } 96 | 97 | // If custom domains are not used, 98 | // and it is either a 404, fallback or localized route, 99 | // (so it is not a registered, non localized route) 100 | // update the locale slug in the URI. 101 | if ( ! LocaleConfig::hasCustomDomains() && ($this->is404() || $this->isLocalized())) { 102 | $urlBuilder->setSlugs($this->updateLocaleInSlugs($urlBuilder->getSlugs(), $locale)); 103 | } 104 | 105 | // If custom domains are used, 106 | // find the one for the requested locale. 107 | if ($domain = LocaleConfig::findDomainByLocale($locale)) { 108 | $urlBuilder->setHost($domain); 109 | } 110 | 111 | return $urlBuilder->build($absolute); 112 | } 113 | 114 | /** 115 | * Generate a URL for a named route. 116 | * 117 | * @param string $locale 118 | * @param array $parameters 119 | * @param bool $absolute 120 | * 121 | * @return string 122 | */ 123 | protected function generateNamedRouteURL(string $locale, array $parameters = [], bool $absolute = true): string 124 | { 125 | try { 126 | return URL::route($this->route->getName(), $parameters, $absolute, $locale); 127 | } catch (RouteNotFoundException $e) { 128 | return ''; 129 | } 130 | } 131 | 132 | /** 133 | * Check if the current route is localized. 134 | * 135 | * @return bool 136 | */ 137 | protected function isLocalized(): bool 138 | { 139 | $routeAction = LocaleConfig::getRouteAction(); 140 | 141 | return $this->routeExists() && $this->route->getAction($routeAction) !== null; 142 | } 143 | 144 | /** 145 | * Check if the current request is a 404. 146 | * Default 404 requests will not have a Route. 147 | * 148 | * @return bool 149 | */ 150 | protected function is404(): bool 151 | { 152 | return ! $this->routeExists() || $this->isFallback(); 153 | } 154 | 155 | /** 156 | * Check if the current route is a fallback route. 157 | * 158 | * @return bool 159 | */ 160 | protected function isFallback(): bool 161 | { 162 | return $this->routeExists() && $this->route->isFallback; 163 | } 164 | 165 | /** 166 | * Check if the current Route exists. 167 | * 168 | * @return bool 169 | */ 170 | protected function routeExists(): bool 171 | { 172 | return $this->route !== null; 173 | } 174 | 175 | /** 176 | * Get the locale from the slugs if it exists. 177 | * 178 | * @param array $slugs 179 | * 180 | * @return string|null 181 | */ 182 | protected function getLocaleFromSlugs(array $slugs): ?string 183 | { 184 | $locale = $slugs[0] ?? null; 185 | 186 | if (LocaleConfig::hasCustomSlugs()) { 187 | $locale = LocaleConfig::findLocaleBySlug($locale); 188 | } 189 | 190 | return ($locale && LocaleConfig::isSupportedLocale($locale)) ? $locale : null; 191 | } 192 | 193 | /** 194 | * Localize the URL path. 195 | * 196 | * @param array $slugs 197 | * @param string $locale 198 | * 199 | * @return array 200 | */ 201 | protected function updateLocaleInSlugs(array $slugs, string $locale): array 202 | { 203 | $slug = LocaleConfig::findSlugByLocale($locale); 204 | 205 | if ($this->getLocaleFromSlugs($slugs)) { 206 | array_shift($slugs); 207 | } 208 | 209 | if ($locale !== LocaleConfig::getOmittedLocale()) { 210 | array_unshift($slugs, $slug); 211 | } 212 | 213 | return $slugs; 214 | } 215 | 216 | /** 217 | * Determine what query string parameters to use. 218 | * 219 | * @param array $requestQueryString 220 | * @param array $queryStringParameters 221 | * @param bool $keepQuery 222 | * 223 | * @return array 224 | */ 225 | protected function determineQueryStringParameters(array $requestQueryString, array $queryStringParameters, bool $keepQuery): array 226 | { 227 | if ($keepQuery === false) { 228 | return []; 229 | } 230 | 231 | if (count($queryStringParameters) > 0) { 232 | return $queryStringParameters; 233 | } 234 | 235 | return $requestQueryString; 236 | } 237 | 238 | /** 239 | * Extract URI parameters and query string parameters. 240 | * 241 | * @param string $uri 242 | * @param array $parameters 243 | * 244 | * @return array 245 | */ 246 | protected function extractRouteAndQueryStringParameters(string $uri, array $parameters): array 247 | { 248 | preg_match_all('/{([a-zA-Z_.-]+\??)}/', $uri, $matches); 249 | $placeholders = $matches[1] ?? []; 250 | 251 | $routePlaceholders = []; 252 | $routeParameters = []; 253 | $queryStringParameters = []; 254 | $i = 0; 255 | 256 | foreach ($parameters as $key => $value) { 257 | // Parameters should be in the same order as the placeholders. 258 | // $key can be a name or an index, so grab the matching key name from the URI. 259 | $placeholder = $placeholders[$i] ?? null; 260 | 261 | // If there is a matching $paramKey, 262 | // we are dealing with a normal parameter, 263 | // else we are dealing with a query string parameter. 264 | if ($placeholder) { 265 | $parameterKey = trim($placeholder, '?'); 266 | $routeParameters[$parameterKey] = $value; 267 | $routePlaceholders["{{$placeholder}}"] = $value; 268 | } else { 269 | $queryStringParameters[$key] = $value; 270 | } 271 | 272 | $i++; 273 | } 274 | 275 | return [$routePlaceholders, $routeParameters, $queryStringParameters]; 276 | } 277 | 278 | /** 279 | * Replace parameter placeholders with their value. 280 | * 281 | * @param string $uri 282 | * @param array $parameters 283 | * 284 | * @return string 285 | */ 286 | protected function replaceParameterPlaceholders(string $uri, array $parameters): string 287 | { 288 | foreach ($parameters as $placeholder => $value) { 289 | $uri = str_replace($placeholder, $value, $uri); 290 | } 291 | 292 | // Remove any optional placeholders that were not provided. 293 | $uri = preg_replace('/{[a-zA-Z_.-]+\?}/', '', $uri); 294 | 295 | return $uri; 296 | } 297 | 298 | /** 299 | * Normalize any route parameters. 300 | * 301 | * @param string $locale 302 | * @param mixed $parameters 303 | * 304 | * @return array 305 | */ 306 | protected function normalizeParameters(string $locale, $parameters): array 307 | { 308 | $models = Collection::make($parameters)->filter(function ($model) { 309 | return $model instanceof ProvidesRouteParameters; 310 | }); 311 | 312 | if ($models->count()) { 313 | $parameters = $models->flatMap(function ($model) use ($locale) { 314 | return $model->getRouteParameters($locale); 315 | })->all(); 316 | } 317 | 318 | if (is_callable($parameters)) { 319 | $parameters = $parameters($locale); 320 | } 321 | 322 | foreach ($parameters as $key => $parameter) { 323 | if ($parameter instanceof UrlRoutable) { 324 | $parameters[$key] = $this->getLocalizedRouteKey($key, $parameter, $locale); 325 | } 326 | } 327 | 328 | return $parameters; 329 | } 330 | 331 | /** 332 | * Get the current route's parameters. 333 | * 334 | * @return array 335 | */ 336 | protected function getRouteParameters(): array 337 | { 338 | return $this->routeExists() ? $this->route->parameters() : []; 339 | } 340 | 341 | /** 342 | * Get the localized route key from a model. 343 | * 344 | * @param string $key 345 | * @param \Illuminate\Contracts\Routing\UrlRoutable $model 346 | * @param string $locale 347 | * 348 | * @return string 349 | */ 350 | protected function getLocalizedRouteKey(string $key, UrlRoutable $model, string $locale): string 351 | { 352 | $originalLocale = App::getLocale(); 353 | 354 | App::setLocale($locale); 355 | 356 | $bindingField = $this->getBindingFieldFor($key); 357 | $routeKey = $bindingField ? $model->$bindingField : $model->getRouteKey(); 358 | 359 | App::setLocale($originalLocale); 360 | 361 | return $routeKey; 362 | } 363 | 364 | /** 365 | * Get the binding field for the current route. 366 | * 367 | * The binding field is the custom route key that you can define in your route: 368 | * Route::get('path/{model:key}') 369 | * If you did not use a custom key, we'll use the default route key. 370 | * 371 | * @param string|int $key 372 | * 373 | * @return string|null 374 | */ 375 | protected function getBindingFieldFor($key): ?string 376 | { 377 | return $this->route->bindingFieldFor($key); 378 | } 379 | } 380 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Localized Routes 2 | 3 | [![GitHub release](https://img.shields.io/github/release/opgginc/codezero-laravel-localized-routes.svg?style=flat-square)](https://github.com/opgginc/codezero-laravel-localized-routes/releases) 4 | [![Laravel](https://img.shields.io/badge/laravel-12-red?style=flat-square&logo=laravel&logoColor=white)](https://laravel.com) 5 | [![License](https://img.shields.io/packagist/l/opgginc/codezero-laravel-localized-routes.svg?style=flat-square)](LICENSE.md) 6 | [![Build Status](https://img.shields.io/github/actions/workflow/status/opgginc/codezero-laravel-localized-routes/run-tests.yml?style=flat-square&logo=github&logoColor=white&label=tests)](https://github.com/opgginc/codezero-laravel-localized-routes/actions) 7 | [![Total Downloads](https://img.shields.io/packagist/dt/opgginc/codezero-laravel-localized-routes.svg?style=flat-square)](https://packagist.org/packages/opgginc/codezero-laravel-localized-routes) 8 | 9 | A convenient way to set up and use localized routes in a Laravel app. 10 | 11 | ## Important Note 12 | 13 | This package is a fork of the original [codezero/laravel-localized-routes](https://github.com/codezero-be/laravel-localized-routes) created by Ivan Vermeyen. It was forked by OP.GG Inc. to maintain and extend its functionality after the unfortunate passing of Ivan. We are deeply grateful for Ivan's contribution to the Laravel community and hope to honor his legacy by continuing to maintain this package. Rest in peace, Ivan. 14 | 15 | ## 📖 Table of Contents 16 | 17 | - [Requirements](#-requirements) 18 | - [Upgrade](#-upgrade) 19 | - [Install](#-install) 20 | - [Configure](#-configure) 21 | - [Publish Configuration File](#-publish-configuration-file) 22 | - [Configure Supported Locales](#-configure-supported-locales) 23 | - [Simple Locales](#simple-locales) 24 | - [Custom Slugs](#custom-slugs) 25 | - [Custom Domains](#custom-domains) 26 | - [Use Fallback Locale](#-use-fallback-locale) 27 | - [Omit Slug for Main Locale](#-omit-slug-for-main-locale) 28 | - [Scoped Options](#-scoped-options) 29 | - [Add Middleware to Update App Locale](#-add-middleware-to-update-app-locale) 30 | - [Detectors](#detectors) 31 | - [Stores](#stores) 32 | - [Register Routes](#-register-routes) 33 | - [Translate Parameters with Route Model Binding](#-translate-parameters-with-route-model-binding) 34 | - [Translate Hard-Coded URI Slugs](#-translate-hard-coded-uri-slugs) 35 | - [Localize 404 Pages](#-localize-404-pages) 36 | - [Cache Routes](#-cache-routes) 37 | - [Generate Route URLs](#-generate-route-urls) 38 | - [Generate URLs for the Active Locale](#-generate-urls-for-the-active-locale) 39 | - [Generate URLs for a Specific Locale](#-generate-urls-for-a-specific-locale) 40 | - [Generate URLs with Localized Parameters](#-generate-urls-with-localized-parameters) 41 | - [Fallback URLs](#-fallback-urls) 42 | - [Generate Localized Versions of the Current URL](#-generate-localized-versions-of-the-current-url) 43 | - [Example Locale Switcher](#-example-locale-switcher) 44 | - [Generate Signed Route URLs](#-generate-signed-route-urls) 45 | - [Redirect to Routes](#-redirect-to-routes) 46 | - [Automatically Redirect to Localized URLs](#-automatically-redirect-to-localized-urls) 47 | - [Helpers](#-helpers) 48 | - [Testing](#-testing) 49 | - [Credits](#-credits) 50 | - [Security](#-security) 51 | - [Changelog](#-changelog) 52 | - [License](#-license) 53 | 54 | ## ✅ Requirements 55 | 56 | - PHP >= 8.1, >= 8.4 57 | - Laravel >= 10, >= 12 58 | - Composer ^2.3 (for [codezero/composer-preload-files](https://github.com/codezero-be/composer-preload-files)) 59 | 60 | ## ⬆ Upgrade 61 | 62 | Upgrading to a new major version? 63 | Check our [upgrade guide](UPGRADE.md) for instructions. 64 | 65 | ## 📦 Install 66 | 67 | Install this package with Composer: 68 | 69 | ```bash 70 | composer require opgginc/codezero-laravel-localized-routes 71 | ``` 72 | 73 | Laravel will automatically register the ServiceProvider. 74 | 75 | ## ⚙ Configure 76 | 77 | ### ☑ Publish Configuration File 78 | 79 | ```bash 80 | php artisan vendor:publish --provider="CodeZero\LocalizedRoutes\LocalizedRoutesServiceProvider" --tag="config" 81 | ``` 82 | 83 | You will now find a `localized-routes.php` file in the `config` folder. 84 | 85 | ### ☑ Configure Supported Locales 86 | 87 | #### Simple Locales 88 | 89 | Add any locales you wish to support to your published `config/localized-routes.php` file: 90 | 91 | ```php 92 | 'supported_locales' => ['en', 'nl']; 93 | ``` 94 | 95 | These locales will be used as a slug, prepended to the URL of your localized routes. 96 | 97 | #### Custom Slugs 98 | 99 | You can also use a custom slug for a locale: 100 | 101 | ```php 102 | 'supported_locales' => [ 103 | 'en' => 'english-slug', 104 | 'nl' => 'dutch-slug', 105 | ]; 106 | ``` 107 | 108 | #### Custom Domains 109 | 110 | Or you can use a custom domain for a locale: 111 | 112 | ```php 113 | 'supported_locales' => [ 114 | 'en' => 'english-domain.test', 115 | 'nl' => 'dutch-domain.test', 116 | ]; 117 | ``` 118 | 119 | ### ☑ Use Fallback Locale 120 | 121 | When using the `route()` helper to generate a URL for a locale that is not supported, a `Symfony\Component\Routing\Exception\RouteNotFoundException` is thrown by Laravel. 122 | However, you can configure a fallback locale to attempt to resolve a fallback URL instead. 123 | If that fails too, the exception is thrown. 124 | 125 | ```php 126 | 'fallback_locale' => 'en', 127 | ``` 128 | 129 | ### ☑ Omit Slug for Main Locale 130 | 131 | Specify your main locale if you want to omit its slug from the URL: 132 | 133 | ```php 134 | 'omitted_locale' => 'en', 135 | ``` 136 | 137 | This option has no effect if you use domains instead of slugs. 138 | 139 | ### ☑ Scoped Options 140 | 141 | To set an option for one localized route group only, you can specify it as the second parameter of the localized route macro. 142 | This will override the config file settings. Currently, only 2 options can be overridden. 143 | 144 | ```php 145 | Route::localized(function () { 146 | Route::get('about', [AboutController::class, 'index']); 147 | }, [ 148 | 'supported_locales' => ['en', 'nl', 'fr'], 149 | 'omitted_locale' => 'en', 150 | ]); 151 | ``` 152 | 153 | ## 🧩 Add Middleware to Update App Locale 154 | 155 | By default, the app locale will always be what you configured in `config/app.php`. 156 | To automatically update the app locale, you need to register the middleware in the `web` middleware group. 157 | Make sure to add it after `StartSession` and before `SubstituteBindings`. 158 | 159 | The order of the middleware is important if you are using localized route keys (translated slugs)! 160 | The session needs to be active when setting the locale, and the locale needs to be set when substituting the route bindings. 161 | 162 | ### Laravel 11 and 12: 163 | 164 | Add the middleware to the `web` middleware group in `bootstrap/app.php`. 165 | 166 | ```php 167 | // bootstrap/app.php 168 | ->withMiddleware(function (Middleware $middleware) { 169 | $middleware->web(remove: [ 170 | \Illuminate\Routing\Middleware\SubstituteBindings::class, 171 | ]); 172 | $middleware->web(append: [ 173 | \CodeZero\LocalizedRoutes\Middleware\SetLocale::class, 174 | \Illuminate\Routing\Middleware\SubstituteBindings::class, 175 | ]); 176 | }) 177 | ``` 178 | 179 | ### Laravel 10: 180 | 181 | Add the middleware to the `web` middleware group in `app/Http/Kernel.php`. 182 | 183 | ```php 184 | // app/Http/Kernel.php 185 | protected $middlewareGroups = [ 186 | 'web' => [ 187 | //... 188 | \Illuminate\Session\Middleware\StartSession::class, // <= after this 189 | //... 190 | \CodeZero\LocalizedRoutes\Middleware\SetLocale::class, 191 | \Illuminate\Routing\Middleware\SubstituteBindings::class, // <= before this 192 | ], 193 | ]; 194 | ``` 195 | 196 | ### Detectors 197 | 198 | The middleware runs the following detectors in sequence, until one returns a supported locale: 199 | 200 | | # | Detector | Description | 201 | |:---:|-------------------------|------------------------------------------------------------------------| 202 | | 1. | `RouteActionDetector` | Required. The locales of a localized route is saved in a route action. | 203 | | 2. | `UrlDetector` | Required. Tries to find a locale based on the URL slugs or domain. | 204 | | 3. | `OmittedLocaleDetector` | Required if an omitted locale is configured. This will always be used. | 205 | | 4. | `UserDetector` | Checks a configurable `locale` attribute on the authenticated user. | 206 | | 5. | `SessionDetector` | Checks the session for a previously stored locale. | 207 | | 6. | `CookieDetector` | Checks a cookie for a previously stored locale. | 208 | | 7. | `BrowserDetector` | Checks the preferred language settings of the visitor's browser. | 209 | | 8. | `AppDetector` | Required. Checks the default app locale as a last resort. | 210 | 211 | Update the `detectors` array in the config file to choose which detectors to run and in what order. 212 | 213 | > You can create your own detector by implementing the `CodeZero\LocalizedRoutes\Middleware\Detectors\Detector` interface and add a reference to it in the config file. The detectors are resolved from Laravel's IOC container, so you can add any dependencies to your constructor. 214 | 215 | ### Stores 216 | 217 | If a supported locale is detected, it will automatically be stored in: 218 | 219 | | # | Store | Description | 220 | |:---:|----------------|-----------------------------------------------------| 221 | | 1. | `SessionStore` | Stores the locale in the session. | 222 | | 2. | `CookieStore` | Stores the locale in a cookie. | 223 | | 3. | `AppStore` | Required. Sets the locale as the active app locale. | 224 | 225 | Update the `stores` array in the config to choose which stores to use. 226 | 227 | > You can create your own store by implementing the `CodeZero\LocalizedRoutes\Middleware\Stores\Store` interface and add a reference to it in the config file. The stores are resolved from Laravel's IOC container, so you can add any dependencies to your constructor. 228 | 229 | Although no further configuration is needed, you can change advanced settings in the config file. 230 | 231 | ## 🚘 Register Routes 232 | 233 | Define your routes inside the `Route::localized()` closure, to automatically register them for each locale. 234 | 235 | This will prepend the locale to the route's URI and name. 236 | If you configured custom domains, it will use those instead of the URI slugs. 237 | You can also use route groups inside the closure. 238 | 239 | ```php 240 | Route::localized(function () { 241 | Route::get('about', [AboutController::class, 'index'])->name('about'); 242 | }); 243 | ``` 244 | 245 | With supported locales `['en', 'nl']`, the above would register: 246 | 247 | | URI | Name | 248 | |-------------|------------| 249 | | `/en/about` | `en.about` | 250 | | `/nl/about` | `nl.about` | 251 | 252 | And with the omitted locale set to `en`, the result would be: 253 | 254 | | URI | Name | 255 | |-------------|------------| 256 | | `/about` | `en.about` | 257 | | `/nl/about` | `nl.about` | 258 | 259 | > In a most practical scenario, you would register a route either localized or non-localized, but not both. 260 | > If you do, you will always need to specify a locale to generate the URL with the `route()` helper, because existing route names always have priority. 261 | > Especially when omitting a main locale from the URL, this would be problematic, because you can't have, for example, a localized `/about` route and a non-localized `/about` route in this case. 262 | > The same idea applies to the `/` (root) route! Also note that the route names still have the locale prefix even if the slug is omitted. 263 | 264 | ### ☑ Translate Parameters with Route Model Binding 265 | 266 | When resolving incoming route parameters from a request, you probably rely on [Laravel's route model binding](https://laravel.com/docs/routing#route-model-binding). 267 | You typehint a model in the controller, and it will look for a `{model}` by its ID, or by a specific attribute like `{model:slug}`. 268 | If it finds one that matches the parameter value in the URL, it is injected in the controller. 269 | 270 | ```php 271 | // Example: use the post slug as the route parameter 272 | Route::get('posts/{post:slug}', [PostsController::class, 'index']); 273 | 274 | // PostsController.php 275 | public function index(Post $post) 276 | { 277 | return $post; 278 | } 279 | ``` 280 | 281 | However, to resolve a localized parameter you need to add a `resolveRouteBinding()` method to your model. 282 | In this method you need to write the logic required to find a match, using the parameter value from the URL. 283 | 284 | For example, you might have a JSON column in your database containing translated slugs: 285 | 286 | ```php 287 | public function resolveRouteBinding($value, $field = null) 288 | { 289 | // Default field to query if no parameter field is specified 290 | $field = $field ?: $this->getRouteKeyName(); 291 | 292 | // If the parameter field is 'slug', 293 | // lets query a JSON field with translations 294 | if ($field === 'slug') { 295 | $field .= '->' . App::getLocale(); 296 | } 297 | 298 | // Perform the query to find the parameter value in the database 299 | return $this->where($field, $value)->firstOrFail(); 300 | } 301 | ``` 302 | 303 | > If you are looking for a good solution to implement translated attributes on your models, be sure to check out [spatie/laravel-translatable](https://github.com/spatie/laravel-translatable). 304 | 305 | ### ☑ Translate Hard-Coded URI Slugs 306 | 307 | This package includes [opgginc/codezero-laravel-uri-translator](https://github.com/opgginc/codezero-laravel-uri-translator). 308 | This registers a `Lang::uri()` macro that enables you to translate individual, hard-coded URI slugs. 309 | Route parameters will not be translated by this macro. 310 | 311 | Routes with translated URIs need to have a name in order to generate localized versions of it using the `route()` helper or the `Route::localizedUrl()` macro. 312 | Because these routes have different slugs depending on the locale, the route name is the only thing that links them together. 313 | 314 | First, you create a `routes.php` translation file in your app's `lang` folder for each locale, for example: 315 | 316 | ```php 317 | lang/nl/routes.php 318 | lang/fr/routes.php 319 | ``` 320 | 321 | Then you add the appropriate translations to each file: 322 | 323 | ```php 324 | // lang/nl/routes.php 325 | return [ 326 | 'about' => 'over', 327 | 'us' => 'ons', 328 | ]; 329 | ``` 330 | 331 | And finally, you use the macro when registering routes: 332 | 333 | ```php 334 | Route::localized(function () { 335 | Route::get(Lang::uri('about/us'), [AboutController::class, 'index'])->name('about'); 336 | }); 337 | ``` 338 | 339 | The URI macro accepts 2 additional parameters: 340 | 341 | 1. A locale, in case you need translations to a locale other than the current app locale. 342 | 2. A namespace, in case your translation files reside in a package. 343 | 344 | ```php 345 | Lang::uri('hello/world', 'fr', 'my-package'); 346 | ``` 347 | 348 | You can also use `trans()->uri('hello/world')` instead of `Lang::uri('hello/world')`. 349 | 350 | ### Example 351 | 352 | Using these example translations: 353 | 354 | ```php 355 | // lang/nl/routes.php 356 | return [ 357 | 'hello' => 'hallo', 358 | 'world' => 'wereld', 359 | 'override/hello/world' => 'something/very/different', 360 | 'hello/world/{parameter}' => 'uri/with/{parameter}', 361 | ]; 362 | ``` 363 | 364 | These are possible translation results: 365 | 366 | ```php 367 | // Translate every slug individually 368 | // Translates to: 'hallo/wereld' 369 | Lang::uri('hello/world'); 370 | 371 | // Keep original slug when missing translation 372 | // Translates to: 'hallo/big/wereld' 373 | Lang::uri('hello/big/world'); 374 | 375 | // Translate slugs, but not parameter placeholders 376 | // Translates to: 'hallo/{world}' 377 | Lang::uri('hello/{world}'); 378 | 379 | // Translate full URIs if an exact translation exists 380 | // Translates to: 'something/very/different' 381 | Lang::uri('override/hello/world'); 382 | 383 | // Translate full URIs if an exact translation exists (with placeholder) 384 | // Translates to: 'uri/with/{parameter}' 385 | Lang::uri('hello/world/{parameter}'); 386 | ``` 387 | 388 | ### 🔦 Localize 404 Pages 389 | 390 | A standard `404` response has no actual `Route` and does not go through the middleware. 391 | This means our middleware will not be able to update the locale and the request can not be localized. 392 | 393 | To fix this, you can register this fallback route at the end of your `routes/web.php` file: 394 | 395 | ```php 396 | Route::fallback(\CodeZero\LocalizedRoutes\Controllers\FallbackController::class); 397 | ``` 398 | 399 | Because the fallback route is an actual `Route`, the middleware will run and update the locale. 400 | 401 | The fallback route is a "catch all" route that Laravel provides. 402 | If you type in a URL that doesn't exist, this route will be triggered instead of a typical 404 exception. 403 | 404 | The `FallbackController` will attempt to respond with a 404 error view, located at `resources/views/errors/404.blade.php`. 405 | If this view does not exist, the normal `Symfony\Component\HttpKernel\Exception\NotFoundHttpException` will be thrown. 406 | You can configure which view to use by changing the `404_view` entry in the config file. 407 | 408 | Fallback routes will not apply when: 409 | 410 | - your existing routes throw a `404` exception (as in `abort(404)`) 411 | - your existing routes throw a `ModelNotFoundException` (like with route model binding) 412 | - your existing routes throw any other exception 413 | 414 | ## 🗄 Cache Routes 415 | 416 | In production, you can safely cache your routes per usual. 417 | 418 | ```bash 419 | php artisan route:cache 420 | ``` 421 | 422 | ## ⚓ Generate Route URLs 423 | 424 | ### ☑ Generate URLs for the Active Locale 425 | 426 | You can get the URL of your named routes as usual, using the `route()` helper. 427 | 428 | ```php 429 | $url = route('about'); 430 | ``` 431 | 432 | If you registered an `about` route that is not localized, then `about` is an existing route name and its URL will be returned. 433 | Otherwise, this will try to generate the `about` URL for the active locale, e.g. `en.about`. 434 | 435 | ### ☑ Generate URLs for a Specific Locale 436 | 437 | In some cases, you might need to generate a URL for a specific locale. 438 | For this purpose, an additional locale parameter was added to Laravel's `route()` helper. 439 | 440 | ```php 441 | $url = route('about', [], true, 'nl'); // this will load 'nl.about' 442 | ``` 443 | 444 | ### ☑ Generate URLs with Localized Parameters 445 | 446 | There are a number of ways to generate route URLs with localized parameters. 447 | 448 | #### Pass Localized Parameters Manually 449 | 450 | Let's say we have a `Post` model with a `getSlug()` method: 451 | 452 | ```php 453 | public function getSlug($locale = null) 454 | { 455 | $locale = $locale ?: App::getLocale(); 456 | 457 | $slugs = [ 458 | 'en' => 'en-slug', 459 | 'nl' => 'nl-slug', 460 | ]; 461 | 462 | return $slugs[$locale] ?? ''; 463 | } 464 | ``` 465 | 466 | > Of course, in a real project the slugs wouldn't be hard-coded. 467 | > If you are looking for a good solution to implement translated attributes on your models, be sure to check out [spatie/laravel-translatable](https://github.com/spatie/laravel-translatable). 468 | 469 | Now you can pass a localized slug to the `route()` function: 470 | 471 | ```php 472 | route('posts.show', [$post->getSlug()]); 473 | route('posts.show', [$post->getSlug('nl')], true, 'nl'); 474 | ``` 475 | 476 | #### Use a Custom Localized Route Key 477 | 478 | You can let Laravel resolve localized parameters automatically by adding the `getRouteKey()` method to your model: 479 | 480 | ```php 481 | public function getRouteKey() 482 | { 483 | $locale = App::getLocale(); 484 | 485 | $slugs = [ 486 | 'en' => 'en-slug', 487 | 'nl' => 'nl-slug', 488 | ]; 489 | 490 | return $slugs[$locale] ?? ''; 491 | } 492 | ``` 493 | 494 | Now you can just pass the model: 495 | 496 | ```php 497 | route('posts.show', [$post]); 498 | route('posts.show', [$post], true, 'nl'); 499 | ``` 500 | 501 | ### ☑ Fallback URLs 502 | 503 | A fallback locale can be provided in the config file. 504 | If the locale parameter for the `route()` helper is not a supported locale, the fallback locale will be used instead. 505 | 506 | ```php 507 | // When the fallback locale is set to 'en' 508 | // and the supported locales are 'en' and 'nl' 509 | 510 | $url = route('about', [], true, 'nl'); // this will load 'nl.about' 511 | $url = route('about', [], true, 'wk'); // this will load 'en.about' 512 | ``` 513 | 514 | If neither a regular nor a localized route can be resolved, a `Symfony\Component\Routing\Exception\RouteNotFoundException` will be thrown. 515 | 516 | ### ☑ Generate Localized Versions of the Current URL 517 | 518 | To generate a URL for the current route in any locale, you can use the `Route::localizedUrl()` macro. 519 | 520 | #### Pass Parameters Manually 521 | 522 | Just like with the `route()` helper, you can pass parameters as a second argument. 523 | 524 | Let's say we have a `Post` model with a `getSlug()` method: 525 | 526 | ```php 527 | public function getSlug($locale = null) 528 | { 529 | $locale = $locale ?: App::getLocale(); 530 | 531 | $slugs = [ 532 | 'en' => 'en-slug', 533 | 'nl' => 'nl-slug', 534 | ]; 535 | 536 | return $slugs[$locale] ?? ''; 537 | } 538 | ``` 539 | 540 | Now you can pass a localized slug to the macro: 541 | 542 | ```php 543 | $current = Route::localizedUrl(null, [$post->getSlug()]); 544 | $en = Route::localizedUrl('en', [$post->getSlug('en')]); 545 | $nl = Route::localizedUrl('nl', [$post->getSlug('nl')]); 546 | ``` 547 | 548 | #### Use a Custom Route Key 549 | 550 | If you add the model's `getRouteKey()` method, you don't need to pass the parameter at all. 551 | 552 | ```php 553 | public function getRouteKey() 554 | { 555 | $locale = App::getLocale(); 556 | 557 | $slugs = [ 558 | 'en' => 'en-slug', 559 | 'nl' => 'nl-slug', 560 | ]; 561 | 562 | return $slugs[$locale] ?? ''; 563 | } 564 | ``` 565 | 566 | The macro will now automatically figure out what parameters the current route has and fetch the values. 567 | 568 | ```php 569 | $current = Route::localizedUrl(); 570 | $en = Route::localizedUrl('en'); 571 | $nl = Route::localizedUrl('nl'); 572 | ``` 573 | 574 | #### Multiple Route Keys 575 | 576 | If you have a route with multiple keys, like `/en/posts/{id}/{slug}`, then you can implement the `ProvidesRouteParameters` interface in your model. 577 | From the `getRouteParameters()` method, you then return the required parameter values. 578 | 579 | ```php 580 | use CodeZero\LocalizedRoutes\ProvidesRouteParameters; 581 | use Illuminate\Database\Eloquent\Model; 582 | 583 | class Post extends Model implements ProvidesRouteParameters 584 | { 585 | public function getRouteParameters($locale = null) 586 | { 587 | return [ 588 | $this->id, 589 | $this->getSlug($locale) // Add this method yourself of course :) 590 | ]; 591 | } 592 | } 593 | ``` 594 | 595 | Now, the parameters will still be resolved automatically: 596 | 597 | ```php 598 | $current = Route::localizedUrl(); 599 | $en = Route::localizedUrl('en'); 600 | $nl = Route::localizedUrl('nl'); 601 | ``` 602 | 603 | #### Keep or Remove Query String 604 | 605 | By default, the query string will be included in the generated URL. 606 | If you don't want this, you can pass an extra parameter to the macro: 607 | 608 | ```php 609 | $keepQuery = false; 610 | $current = Route::localizedUrl(null, [], true, $keepQuery); 611 | ``` 612 | 613 | ### ☑ Example Locale Switcher 614 | 615 | The following Blade snippet will add a link to the current page in every alternative locale. 616 | 617 | It will only run if the current route is localized or a fallback route. 618 | 619 | ```blade 620 | @if (Route::isLocalized() || Route::isFallback()) 621 | 632 | @endif 633 | ``` 634 | 635 | ## 🖋 Generate Signed Route URLs 636 | 637 | Generating a localized signed route and temporary signed route URL is just as easy as generating normal route URLs. 638 | Pass it the route name, the necessary parameters, and you will get the URL for the current locale. 639 | 640 | ```php 641 | $signedUrl = URL::signedRoute('reset.password', ['user' => $id]); 642 | $signedUrl = URL::temporarySignedRoute('reset.password', now()->addMinutes(30), ['user' => $id]); 643 | ``` 644 | 645 | You can also generate a signed route URL for a specific locale: 646 | 647 | ```php 648 | $signedUrl = URL::signedRoute('reset.password', ['user' => $id], null, true, 'nl'); 649 | $signedUrl = URL::temporarySignedRoute('reset.password', now()->addMinutes(30), ['user' => $id], true, 'nl'); 650 | ``` 651 | 652 | Check out the [Laravel docs](https://laravel.com/docs/urls#signed-urls) for more info on signed routes. 653 | 654 | ## 🚌 Redirect to Routes 655 | 656 | You can redirect to routes, just like you would in a normal Laravel app, using the `redirect()` helper or the `Redirect` facade. 657 | 658 | If you register an `about` route that is not localized, then `about` is an existing route name and its URL will be redirected to. 659 | Otherwise, this will try to redirect to the `about` route for the active locale, e.g. `en.about`: 660 | 661 | ```php 662 | return redirect()->route('about'); 663 | ``` 664 | 665 | You can also redirect to URLs in a specific locale: 666 | 667 | ```php 668 | // Redirects to 'nl.about' 669 | return redirect()->route('about', [], 302, [], 'nl'); 670 | ``` 671 | 672 | A localized version of the `signedRoute` and `temporarySignedRoute` redirects are included as well: 673 | 674 | ```php 675 | // Redirects to the active locale 676 | return redirect()->signedRoute('signed.route', ['user' => $id]); 677 | return redirect()->temporarySignedRoute('signed.route', now()->addMinutes(30), ['user' => $id]); 678 | 679 | // Redirects to 'nl.signed.route' 680 | return redirect()->signedRoute('signed.route', ['user' => $id], null, 302, [], 'nl'); 681 | return redirect()->temporarySignedRoute('signed.route', now()->addMinutes(30), ['user' => $id], 302, [], 'nl'); 682 | ``` 683 | 684 | ## 🪧 Automatically Redirect to Localized URLs 685 | 686 | To redirect any non-localized URL to its localized version, you can set the config option `redirect_to_localized_urls` to `true`, and register the following fallback route with the `FallbackController` at the end of your `routes/web.php` file. 687 | 688 | ```php 689 | Route::fallback(\CodeZero\LocalizedRoutes\Controllers\FallbackController::class); 690 | ``` 691 | 692 | The fallback route is a "catch all" route that Laravel provides. 693 | If you type in a URL that doesn't exist, this route will be triggered instead of a typical 404 exception. 694 | 695 | The `FallbackController` will attempt to redirect to a localized version of the URL, or return a [localized 404 response](#-localize-404-pages) if it doesn't exist. 696 | 697 | For example: 698 | 699 | | URI | Redirects To | 700 | |----------|--------------| 701 | | `/` | `/en` | 702 | | `/about` | `/en/about` | 703 | 704 | If the omitted locale is set to `en`: 705 | 706 | | URI | Redirects To | 707 | |-------------|--------------| 708 | | `/en` | `/` | 709 | | `/en/about` | `/about` | 710 | 711 | If a route doesn't exist, a `404` response will be returned. 712 | 713 | ## 🪜 Helpers 714 | 715 | ### `Route::hasLocalized()` 716 | 717 | ```php 718 | // Check if a named route exists in the active locale: 719 | $exists = Route::hasLocalized('about'); 720 | // Check if a named route exists in a specific locale: 721 | $exists = Route::hasLocalized('about', 'nl'); 722 | ``` 723 | 724 | ### `Route::isLocalized()` 725 | 726 | ```php 727 | // Check if the current route is localized: 728 | $isLocalized = Route::isLocalized(); 729 | // Check if the current route is localized and has a specific name: 730 | $isLocalized = Route::isLocalized('about'); 731 | // Check if the current route has a specific locale and has a specific name: 732 | $isLocalized = Route::isLocalized('about', 'nl'); 733 | // Check if the current route is localized and its name matches a pattern: 734 | $isLocalized = Route::isLocalized(['admin.*', 'dashboard.*']); 735 | // Check if the current route has one of the specified locales and has a specific name: 736 | $isLocalized = Route::isLocalized('about', ['en', 'nl']); 737 | ``` 738 | 739 | ### `Route::isFallback()` 740 | 741 | ```php 742 | // Check if the current route is a fallback route: 743 | $isFallback = Route::isFallback(); 744 | ``` 745 | 746 | ## 🚧 Testing 747 | 748 | ```bash 749 | composer test 750 | ``` 751 | 752 | ## ☕ Credits 753 | 754 | - [Ivan Vermeyen](https://github.com/ivanvermeyen) 755 | - [OP.GG Inc.](https://github.com/opgginc) 756 | - [All contributors](https://github.com/opgginc/codezero-laravel-localized-routes/contributors) 757 | 758 | ## 🔒 Security 759 | 760 | If you discover any security related issues, please contact us via GitHub issues. 761 | 762 | ## 📑 Changelog 763 | 764 | A complete list of all notable changes to this package can be found on the 765 | [releases page](https://github.com/opgginc/codezero-laravel-localized-routes/releases). 766 | 767 | ## 📜 License 768 | 769 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 770 | --------------------------------------------------------------------------------