
Привет, Хабр!
UPD
На этом ресурсе актульность статьи может оказаться умноженной на ноль одним комментарием. Задача описанная в статье может быть с меньшей болью решена библиотекой mcamara/laravel-localization.
За наводку спасибо DExploN!
Кат приподнят. Умноженное на ноль — снизу.
Хочу рассказать вам о том, как в одном проекте возникла проблема с роутингом и как мы её решали.
Сначала наш проект был самым обычным сайтом. Сайт развивался, аудитория расширялась и возникла необходимость поддержки мультиязычности. Проект был на базе фреймворка Laravel и проблем с мультиязычностью не возникло (нужный язык подтягивался из сессии, либо брался дефолтный). Мы написали переводы, прописали ключи переводов вместо захардкоженных фраз и взяли в работу следующие фичи.
Проблема
В какой-то момент команда SEO поняла, что такой подход мешает ранжированию сайта. Тогда команде разработки поступила команда добавить языковые подпапки в УРЛ, кроме языка по умолчанию. Наши роуты приняли примерно такой вид:
| Страница | Роут ru (язык по умолчанию) | Роут en | Роут fr | 
|---|---|---|---|
| О нас | /o-nas | 
/en/about-us | 
/fr/a-propos-de-nous | 
| Контакты | /kontakty | 
/en/contacts | 
/fr/coordonnees | 
| Новости | /novosti | 
/en/news | 
/fr/les-nouvelles | 
Всё встало на свои места и мы снова принялись за новые фичи.
Чуть позже возникла необходимость развернуть приложение на нескольких доменах. В целом эти сайты имеют одну БД, но в зависимости от домена могут меняться некоторые настройки.
Некоторые сайты могут быть мультиязычные (причем с ограниченным набором языком, а не со всеми поддерживаемыми), некоторые — только один язык.
Было принято решение обрабатывать все домены одним приложением (nginx проксирует все домены на один апстрим).
Набор поддерживаемых конкретным сайтом языков и язык по умолчанию должен настраиваться в админке, что на корню зарубило вариант конфига/env-переменных. Стало ясно, что текущее решение не удовлетворяет наши хотелки.
Решение
Для упрощения картины и демонстрации решения я развернул новый проект на laravel версии 6.2 и отказался от использования БД. В версиях 5.x отличия незначительные (но расписывать их я, конечно же, не буду).
Код проекта доступен на GitHub
Для начала нам нужно указать в конфигурации приложения все поддерживаемые языки.
<?php
return [
// ... 
    'locale' => 'en',
    'fallback_locale' => 'en',
    'supported_locales' => [
        'en',
        'ru',
        'de',
        'fr',
    ],
// ...
];Нам понадобится сущность сайта Site и сервис определения настроек сайта.
<?php
declare(strict_types=1);
namespace App\Entities;
class Site
{
    /**
     * @var string Домен сайта
     */
    private $domain;
    /**
     * @var string Язык по умолчанию
     */
    private $defaultLanguage;
    /**
     * @var string[] Список поддержиаемых языков
     */
    private $supportedLanguages = [];
    /**
     * @param string   $domain             Домен
     * @param string   $defaultLanguage    Язык по умолчанию
     * @param string[] $supportedLanguages Список поддерживаемых языков
     */
    public function __construct(string $domain, string $defaultLanguage, array $supportedLanguages)
    {
        $this->domain = $domain;
        $this->defaultLanguage = $defaultLanguage;
        if (!in_array($defaultLanguage, $supportedLanguages)) {
            $supportedLanguages[] = $defaultLanguage;
        }
        $this->supportedLanguages = $supportedLanguages;
    }
    /**
     * Возвращает домен сайта
     *
     * @return string
     */
    public function getDomain(): string
    {
        return $this->domain;
    }
    /**
     * Возвращает язык по умолчанию для сайта
     *
     * @return string
     */
    public function getDefaultLanguage(): string
    {
        return $this->defaultLanguage;
    }
    /**
     * Возвращает список поддерживаемых сайтом языков
     *
     * @return string[]
     */
    public function getSupportedLanguages(): array
    {
        return $this->supportedLanguages;
    }
    /**
     * Проверяет поддержку сайтом языка
     *
     * @param string $language
     * @return bool
     */
    public function isLanguageSupported(string $language): bool
    {
        return in_array($language, $this->supportedLanguages);
    }
    /**
     * Проверяет, является ли передаваемый язык основным
     *
     * @param string $language
     * @return bool
     */
    public function isLanguageDefault(string $language): bool
    {
        return $language === $this->defaultLanguage;
    }
}
<?php
declare(strict_types=1);
namespace App\Contracts;
use App\Entities\Site;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
interface SiteDetector
{
    /**
     * Определяет сайт по хосту
     *
     * @param string $host Хост
     *
     * @return Site Сущность сайта
     *
     * @throws NotFoundHttpException Если сайт не известен
     */
    public function detect(string $host): Site;
}
<?php
declare(strict_types=1);
namespace App\Services\SiteDetector;
use App\Contracts\SiteDetector;
use App\Entities\Site;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
 * Для демонстрации сайты находятся в памяти.
 * В реальном проекте всё хранится в БД, что позволяет изменять настройки через админку.
 */
class FakeSiteDetector implements SiteDetector
{
    /**
     * @var Site[] Хранилище
     */
    private $sites;
    public function __construct()
    {
        $sites = [
            'localhost' => [ // Все языки
                'default' => 'en',
                'support' => ['ru', 'de', 'fr'],
            ],
            'site-all.local' => [ // Все языки
                'default' => 'en',
                'support' => ['ru', 'de', 'fr'],
            ],
            'site-ru.local' => [ // Только русский
                'default' => 'ru',
                'support' => [],
            ],
            'site-en.local' => [
                'default' => 'en', // Только английский
                'support' => [],
            ],
            'site-de.local' => [
                'default' => 'de', // Только немецкий
                'support' => [],
            ],
            'site-fr.local' => [
                'default' => 'fr', // Только французский
                'support' => [],
            ],
            'site-eur.local' => [ // Немецкий и французский
                'default' => 'de',
                'support' => ['fr'],
            ],
        ];
        foreach ($sites as $domain => $site) {
            $default = $site['default'];
            $support = array_merge([$default], $site['support']);
            $this->sites[$domain] = new Site($domain, $default, $support);
        }
    }
    public function detect(string $host): Site
    {
        $host = trim(mb_strtolower($host));
        if (!array_key_exists($host, $this->sites)) {
            throw new NotFoundHttpException();
        }
        return $this->sites[$host];
    }
}Добавим наш сервис в контейнер
<?php
namespace App\Providers;
use App\Contracts\SiteDetector;
use App\Services\SiteDetector\FakeSiteDetector;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
    // ...
    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        // ...
        /*
         * Строим сервис.
         */
        $this->app->singleton(FakeSiteDetector::class, function () {
            return new FakeSiteDetector();
        });
        /*
         * Биндим контракт
         */
        $this->app->bind(SiteDetector::class, FakeSiteDetector::class);
        // ...
    }
    // ...
}
Теперь определим роуты.
<?php
// ...
Route::get('/', 'DemoController@home')->name('web.home');
Route::get('/--about--', 'DemoController@about')->name('web.about');
Route::get('/--contacts--', 'DemoController@contacts')->name('web.contacts');
Route::get('/--news--', 'DemoController@news')->name('web.news');
// ...
Части роутов, подлежащие локализации, обрамлены двойными минусами (--). Это маски для замены. Теперь законфигурируем эти маски.
<?php
return [
    'web.about' => [ // Имя роута
        'about' => [ // Маска без обрамляющих символов
            'de' => 'uber-uns', // язык => слаг
            'en' => 'about-us',
            'fr' => 'a-propos-de-nous',
            'ru' => 'o-nas',
        ],
    ],
    'web.news' => [
        'news' => [
            'de' => 'nachrichten',
            'en' => 'news',
            'fr' => 'nouvelles',
            'ru' => 'novosti',
        ],
    ],
    'web.contacts' => [
        'contacts' => [
            'de' => 'kontakte',
            'en' => 'contacts',
            'fr' => 'contacts',
            'ru' => 'kontakty',
        ],
    ],
];
Для отображения компонента выбора языка нам нужно передать в шаблон только те языки, которые поддерживаются сайтом. Напишем для этого мидлварь...
<?php
namespace App\Http\Middleware;
use App\Contracts\SiteDetector;
use Closure;
use Illuminate\Contracts\View\Factory as ViewFactory;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
class ViewData
{
    /**
     * @var ViewFactory
     */
    private $view;
    /**
     * @var SiteDetector
     */
    private $detector;
    public function __construct(ViewFactory $view, SiteDetector $detector)
    {
        $this->view = $view;
        $this->detector = $detector;
    }
    public function handle(Request $request, Closure $next)
    {
        /*
         * Определяем сайт
         */
        $site = $this->detector->detect($request->getHost());
        /*
         * Передаём в шаблон панели выбора языка ссылки
         */
        $languages = [];
        foreach ($site->getSupportedLanguages() as $language) {
            $url = '/';
            if (!$site->isLanguageDefault($language)) {
                $url .= $language;
            }
            $languages[$language] = $url;
        }
        $this->view->composer(['components/languages'], function(View $view) use ($languages) {
            $view->with('languages', $languages);
        });
        return $next($request);
    }
}
Теперь нужно кастомизировать роутер. Вернее не сам роутер, а коллекцию роутов...
<?php
namespace App\Custom\Illuminate\Routing;
use Illuminate\Routing\Route;
use Illuminate\Routing\RouteCollection as BaseRouteCollection;
use Serializable;
class RouteCollection extends BaseRouteCollection implements Serializable
{
    /**
     * @var array Конфигурация локализации роутов.
     */
    private $config;
    private $localized = [];
    public function setConfig(array $config)
    {
        $this->config = $config;
    }
    /**
     * Заменяет маски локлизуемых роутов.
     *
     * @param string $language Язык
     */
    public function localize(string $language)
    {
        $this->flushLocalizedRoutes();
        foreach ($this->config as $name => $placeholders) {
            if (!$this->hasNamedRoute($name) || empty($placeholders)) {
                continue;
            }
            /*
             * Получаем именованный роут
             */
            $route = $this->getByName($name);
            /*
             * Запоминаем
             */
            $this->localized[$name] = $route;
            /*
             * Удаляем его из коллекции
             */
            $this->removeRoute($route);
            /*
             * Меняем шаблон
             */
            $new = clone $route;
            $uri = $new->uri();
            foreach ($placeholders as $placeholder => $paths) {
                if (!array_key_exists($language, $paths)) {
                    continue;
                }
                $value = $paths[$language];
                $uri = str_replace('--' . $placeholder . '--', $value, $uri);
            }
            $new->setUri($uri);
            $this->add($new);
        }
        /*
         * Обновляем индексы
         */
        $this->refreshNameLookups();
        $this->refreshActionLookups();
    }
    private function removeRoute(Route $route)
    {
        $uri = $route->uri();
        $domainAndUri = $route->getDomain().$uri;
        foreach ($route->methods() as $method) {
            $key = $method.$domainAndUri;
            if (array_key_exists($key, $this->allRoutes)) {
                unset($this->allRoutes[$key]);
            }
            if (array_key_exists($uri, $this->routes[$method])) {
                unset($this->routes[$method][$uri]);
            }
        }
    }
    private function flushLocalizedRoutes()
    {
        foreach ($this->localized as $name => $route) {
            /*
             * Получаем именованный роут
             */
            $old = $this->getByName($name);
            /*
             * Удаляем его из коллекции
             */
            $this->removeRoute($old);
            /*
             * Добавляем исходный
             */
            $this->add($route);
        }
    }
    /**
     * @inheritDoc
     */
    public function serialize()
    {
        return serialize([
            'routes' => $this->routes,
            'allRoutes' => $this->allRoutes,
            'nameList' => $this->nameList,
            'actionList' => $this->actionList,
        ]);
    }
    /**
     * @inheritDoc
     */
    public function unserialize($serialized)
    {
        $data = unserialize($serialized);
        $this->routes = $data['routes'];
        $this->allRoutes = $data['allRoutes'];
        $this->nameList = $data['nameList'];
        $this->actionList = $data['actionList'];
    }
}
…, основной класс приложения ...
<?php
namespace App\Custom\Illuminate\Foundation;
use App\Custom\Illuminate\Routing\RouteCollection;
use App\Exceptions\UnsupportedLocaleException;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Foundation\Application as BaseApplication;
use Illuminate\Routing\UrlGenerator;
class Application extends BaseApplication
{
    private $isLocaleEstablished = false;
    private $cachedRoutes = [];
    public function __construct($basePath = null)
    {
        parent::__construct($basePath);
    }
    public function setLocale($locale)
    {
        /*
         * При попытке сменить локаль на уже установленную, не шевелимся.
         */
        if ($this->getLocale() === $locale && $this->isLocaleEstablished) {
            return;
        }
        /** @var Repository $config */
        $config = $this->get('config');
        $urlGenerator = $this->get('url');
        $defaultLocale = $config->get('app.fallback_locale');
        $supportedLocales = $config->get('app.supported_locales');
        /*
         * Проверяем поддержку выбранной локали
         */
        if (!in_array($locale, $supportedLocales)) {
            throw new UnsupportedLocaleException();
        }
        /*
         * Для дополнительных языков добавляем префикс в генераторе УРЛ
         */
        if ($defaultLocale !== $locale && $urlGenerator instanceof UrlGenerator) {
            $request = $urlGenerator->getRequest();
            $rootUrl = $request->getSchemeAndHttpHost() . '/' . $locale;
            $urlGenerator->forceRootUrl($rootUrl);
        }
        /*
         * Проводим обычную процедуру смены локали
         */
        parent::setLocale($locale);
        /*
         * Применяем локализацию к роутам
         */
        if (array_key_exists($locale, $this->cachedRoutes)) {
            $fn = $this->cachedRoutes[$locale];
            $this->get('router')->setRoutes($fn());
        } else {
            $this->get('router')->getRoutes()->localize($locale);
        }
        $this->isLocaleEstablished = true;
    }
    public function bootstrapWith(array $bootstrappers)
    {
        parent::bootstrapWith($bootstrappers);
        /**
         * После бутстрапа роутеру нужно задать конфигурацию локализуемых роутов
         * и задать приложению локаль по умолчанию
         *
         * @var RouteCollection $routes
         */
        $routes = $this->get('router')->getRoutes();
        $routes->setConfig($this->get('config')->get('routes'));
        if ($this->routesAreCached()) {
            /** @noinspection PhpIncludeInspection */
            $this->cachedRoutes = require $this->getCachedRoutesPath();
        }
        $this->setLocale($this->getLocale());
    }
}
… и подменить наши кастомные классы.
<?php
// $app = new Illuminate\Foundation\Application($_ENV['APP_BASE_PATH'] ?? dirname(__DIR__));
$app = new App\Custom\Illuminate\Foundation\Application($_ENV['APP_BASE_PATH'] ?? dirname(__DIR__));
$app->get('router')->setRoutes(new App\Custom\Illuminate\Routing\RouteCollection());
// ...Следующий шаг — определение языка по первой части УРЛ запроса. Для этого перед диспатчингом мы получим первый его сегмент, проверим поддержку такого языка сайтом, и запустим диспатчинг с новым запросом уже без этого сегмента. Немного поправим класс App\Http\Kernel, а заодно добавим наш миддлварь App\Http\Middleware\ViewData в группу web
<?php
namespace App\Http;
// ...
use App\Contracts\SiteDetector;
use App\Http\Middleware\ViewData;
use Closure;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
use Illuminate\Http\Request;
// ...
class Kernel extends HttpKernel
{
    // ...
    /**
     * The application's route middleware groups.
     *
     * @var array
     */
    protected $middlewareGroups = [
        // ...
        'web' => [
            // ...
            ViewData::class,
        ],
        // ...
    ];
    // ...
    /**
     * Get the route dispatcher callback.
     *
     * @return Closure
     */
    protected function dispatchToRouter()
    {
        return function (Request $request) {
            /*
             * Определяем сайт
             */
            /** @var SiteDetector $siteDetector */
            $siteDetector = $this->app->get(SiteDetector::class);
            $site = $siteDetector->detect($request->getHost());
            /*
             * Определяем первый сегмент УРЛ
             */
            $segment = (string)$request->segment(1);
            /*
             * Если первый сегмент УРЛ совпадает с одним из поддерживаемых сайтом языков, значит это язык
             */
            if ($segment && $site->isLanguageSupported($segment)) {
                $language = $segment;
            } else {
                $language = $site->getDefaultLanguage();
            }
            /*
             * Задаём приложению список поддерживаемых локалей
             */
            $this->app->get('config')->set('app.supported_locales', $site->getSupportedLanguages());
            /*
             * Задаём приложению локаль по умолчанию
             */
            $this->app->get('config')->set('app.fallback_locale', $site->getDefaultLanguage());
            /*
             * Задаём приложению локаль
             */
            $this->app->setLocale($language);
            /*
             * Если текущий язык не совпадает с языком сайта по умолчанию
             */
            if (!$site->isLanguageDefault($language)) {
                /*
                 * Вырезаем первый сегмент из УРЛ запроса.
                 */
                $server = $request->server();
                $server['REQUEST_URI'] = mb_substr($server['REQUEST_URI'], mb_strlen($language) + 1);
                $request = $request->duplicate(
                    $request->query->all(),
                    $request->all(),
                    $request->attributes->all(),
                    $request->cookies->all(),
                    $request->files->all(),
                    $server
                );
            }
            /*
             * Запускаем диспатчинг
             */
            $this->app->instance('request', $request);
            return $this->router->dispatch($request);
        };
    }
}
Если не кэшировать роуты, то можно уже работать. Но на бою без кэша — идея не из лучших. Мы уже научили наше приложение получать роуты из кэша, теперь нужно научить правильно его сохранять. Кастомизируем консольную команду route:cache
<?php
declare(strict_types=1);
namespace App\Custom\Illuminate\Foundation\Console;
use App\Custom\Illuminate\Routing\RouteCollection as CustomRouteCollection;
use Illuminate\Routing\Route;
use Illuminate\Routing\RouteCollection;
use Illuminate\Foundation\Console\RouteCacheCommand as BaseCommand;
class RouteCacheCommand extends BaseCommand
{
    /**
     * Execute the console command.
     *
     * @return void
     */
    public function handle()
    {
        /*
         * Сначала удаляем старый кэш
         */
        $this->call('route:clear');
        /*
         * Получаем роуты свежего приложения
         */
        $routes = $this->getFreshApplicationRoutes();
        if (count($routes) === 0) {
            $this->error("Your application doesn't have any routes.");
            return;
        }
        /*
         * Подготавливаем кэш и сохраняем
         */
        $this->files->put(
            $this->laravel->getCachedRoutesPath(), $this->buildRouteCacheFile($routes)
        );
        $this->info('Routes cached successfully!');
        return;
    }
    protected function buildRouteCacheFile(RouteCollection $base)
    {
        /*
         * Кэш файл будет представлять собой массив анонимных функций.
         * 
         * Ключ массива - локаль, значение - функция, возвращающая экземпляр класса Illuminate\Routing\RouteCollection
         */
        $code = '<?php' . PHP_EOL . PHP_EOL;
        $code .= 'return [' . PHP_EOL;
        $stub = '    \'{{key}}\' => function() {return unserialize(base64_decode(\'{{routes}}\'));},';
        foreach (config('app.supported_locales') as $locale) {
            /** @var CustomRouteCollection|Route[] $routes */
            $routes = clone $base;
            $routes->localize($locale);
            foreach ($routes as $route) {
                $route->prepareForSerialization();
            }
            $line = str_replace('{{routes}}', base64_encode(serialize($routes)), $stub);
            $line = str_replace('{{key}}', $locale, $line);
            $code .= $line . PHP_EOL;
        }
        $code .= '];' . PHP_EOL;
        return $code;
    }
}
Команда route:clear просто удаляет файл кэша, Её мы трогать не будем. А вот команде route:list теперь не помешает опция locale.
<?php
declare(strict_types=1);
namespace App\Custom\Illuminate\Foundation\Console;
use Illuminate\Foundation\Console\RouteListCommand as BaseCommand;
use Symfony\Component\Console\Input\InputOption;
class RouteListCommand extends BaseCommand
{
    /**
     * Execute the console command.
     *
     * @return void
     */
    public function handle()
    {
        $locales = $this->option('locale');
        /*
         * Выполняем родительскую команду для каждой локали
         */
        foreach ($locales as $locale) {
            if ($locale && in_array($locale, config('app.supported_locales'))) {
                $this->output->title($locale);
                $this->laravel->setLocale($locale);
                $this->router = $this->laravel->get('router');
                parent::handle();
            }
        }
    }
    protected function getOptions()
    {
        /*
         * Все поддерживаемые приложением локали
         */
        $all = config('app.supported_locales');
        /*
         * Определяем опции родительской команды
         */
        $result = parent::getOptions();
        /*
         * Добавляем опцию локалей
         */
        $result[] = ['locale', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Locales', $all];
        return $result;
    }
}
Теперь нам нужно эти команды заставить работать. Сейчас будут работать вендорные команды. Чтобы заменить реализацию консольных команд, нужно включить в приложение сервис провайдер, реализующий интерфейс Illuminate\Contracts\Support\DeferrableProvider. Метод provides() должен вернуть массив ключей контейра, соответствующих классам команд.
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Custom\Illuminate\Foundation\Console\RouteCacheCommand;
use App\Custom\Illuminate\Foundation\Console\RouteListCommand;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Contracts\Support\DeferrableProvider;
use Illuminate\Support\ServiceProvider;
class CommandsReplaceProvider extends ServiceProvider implements DeferrableProvider
{
    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        $this->app->singleton('command.route.cache', function (Application $app) {
            return new RouteCacheCommand($app->get('files'));
        });
        $this->app->singleton('command.route.list', function (Application $app) {
            return new RouteListCommand($app->get('router'));
        });
        $this->commands($this->provides());
    }
    public function provides()
    {
        return [
            'command.route.cache',
            'command.route.list',
        ];
    }
}
Ну и конечно же, добавляем провайдер в конфигурацию.
<?php
return [
    // ...
    'providers' => [
        App\Providers\CommandsReplaceProvider::class,
    ],
    // ...
];Теперь всё работает!
user@host laravel-localized-routing $ ./artisan route:list
en
==
+--------+----------+----------+--------------+-------------------------+------------+
| Domain | Method   | URI      | Name         | Action                  | Middleware |
+--------+----------+----------+--------------+-------------------------+------------+
|        | GET|HEAD | /        | web.home     | DemoController@home     | web        |
|        | GET|HEAD | about-us | web.about    | DemoController@about    | web        |
|        | GET|HEAD | contacts | web.contacts | DemoController@contacts | web        |
|        | GET|HEAD | news     | web.news     | DemoController@news     | web        |
+--------+----------+----------+--------------+-------------------------+------------+
ru
==
+--------+----------+----------+--------------+-------------------------+------------+
| Domain | Method   | URI      | Name         | Action                  | Middleware |
+--------+----------+----------+--------------+-------------------------+------------+
|        | GET|HEAD | /        | web.home     | DemoController@home     | web        |
|        | GET|HEAD | kontakty | web.contacts | DemoController@contacts | web        |
|        | GET|HEAD | novosti  | web.news     | DemoController@news     | web        |
|        | GET|HEAD | o-nas    | web.about    | DemoController@about    | web        |
+--------+----------+----------+--------------+-------------------------+------------+
de
==
+--------+----------+-------------+--------------+-------------------------+------------+
| Domain | Method   | URI         | Name         | Action                  | Middleware |
+--------+----------+-------------+--------------+-------------------------+------------+
|        | GET|HEAD | /           | web.home     | DemoController@home     | web        |
|        | GET|HEAD | kontakte    | web.contacts | DemoController@contacts | web        |
|        | GET|HEAD | nachrichten | web.news     | DemoController@news     | web        |
|        | GET|HEAD | uber-uns    | web.about    | DemoController@about    | web        |
+--------+----------+-------------+--------------+-------------------------+------------+
fr
==
+--------+----------+------------------+--------------+-------------------------+------------+
| Domain | Method   | URI              | Name         | Action                  | Middleware |
+--------+----------+------------------+--------------+-------------------------+------------+
|        | GET|HEAD | /                | web.home     | DemoController@home     | web        |
|        | GET|HEAD | a-propos-de-nous | web.about    | DemoController@about    | web        |
|        | GET|HEAD | contacts         | web.contacts | DemoController@contacts | web        |
|        | GET|HEAD | nouvelles        | web.news     | DemoController@news     | web        |
+--------+----------+------------------+--------------+-------------------------+------------+
На этом всё. Спасибо за внимание!
Комментарии (10)

DExploN
26.12.2019 09:42+1Могу ошибаться, да и проект уже созданный, но есть готовые решения для такого:
github.com/mcamara/laravel-localization#translated-routes
trawl Автор
26.12.2019 10:03Я на эту библиотеку натыкался, когда искал пути решения. Беглый осмотр выявил 2 критичных момента:
- Роуты дефолтного языка тоже находятся в подпапке. У нас было жесткое требование — для дефолтного языка роуты должны быть без подпапки
 - Если подпапка содержит неподдерживаемый язык, то просто устанавливается дефолтная локаль. С точки зрения СЕО это дублирование контента (например, сайт поддерживает локали 
ruиenс дефолтнойen, тогда страницы/fr/aboutи/en/aboutбудут идентичны, в то время как/fr/aboutдолжна возвращать ошибку 404) 
А так да, для некоторых кейсов либа годная.

DExploN
26.12.2019 10:22+11. Дефолтная локаль без подпапки — настройка такая есть. hideDefaultLocaleInURL
2. Проверил свой проект — у меня возвращает 404 для несуществующей локали. Да и логика этой строчки другая. В данной строке вообще нет проверки на разрешенную/нет локаль. Плюс getForcedLocale берет локаль из env, что вообще в документации поверхностной нет. Т.е это некая дополнительная логика.

PiKkoO
26.12.2019 14:27На той неделе была такая же трабла, решил пакетом из коммента выше. Тоже СЕО отдел мозг колошматил =) Решение простое на мой взгляд (хотя фиг знает верное ли), хранить ресурсы с локалью в БД (две таблицы естессно со связью) и редиректить (этот пакет меняет locale, определить и сравнить на изи). С точки зрения СЕО идеально. Идея взята у MODx с разными контекстами.
          
 
x893
Какой ужас! Неужели всё так через зад. Была такая же проблема лет 8 лет. Детали уже не помню, но точно проще. Хотя .NET был. Языков штук 5 было и доменов/серверов побольше.
trawl Автор
А что именно ужасного и почему через зад?
Возможно, ваше решение проще лишь из-за того, что деталей не помните?
Количество языков роли не играет (кроме затрат времени на переводы), количество доменов/серверов, в общем-то тоже...