Чому я взялася за власний пакет локалізації
Я працюю над власним Laravel-пакетом локалізації, який зможу повторно використовувати в різних продуктах.
Готові пакети добре вирішують типові задачі, але в моєму випадку основні вимоги були трохи іншими. Мені був потрібен не просто спосіб перекласти текст або додати мовний префікс до URL, а окремий інфраструктурний шар для роботи з мовами продукту.
Я не хотіла, щоб пакет сам зберігав перелік мов у власній конфігурації.
У реальному продукті мови можуть керуватися через базу даних, адміністративну панель, налаштування окремого клієнта або зовнішній сервіс. Тому пакет має знати, як працювати з locale, але не повинен вирішувати, звідки ця locale з’явилася.
Основні вимоги до пакета:
перелік мов належить продукту, а не пакету;
джерело мов можна замінити без зміни routing-логіки;
default locale визначається продуктом;
вимкнені мови не беруть участі в маршрутизації;
підтримуються URL із префіксом для всіх мов;
підтримуються URL без префікса для default locale;
application code використовує стабільні назви маршрутів;
пакет може згенерувати поточний URL для іншої мови;
локалізовані маршрути мають працювати з Laravel route cache.
Головна особливість пакета
Продукт передає доступні мови через контракт LocaleProvider.
Сам пакет працює з уже перевіреною колекцією мов і не залежить від того, чи були вони отримані з config, бази даних або іншого джерела.
interface LocaleProvider{ public function locales(): LocaleCollection;}Ще одна важлива особливість - стабільні логічні назви маршрутів.
Код застосунку не повинен знати, які внутрішні route variants створив пакет для різних мов. Незалежно від поточної locale виклик залишається звичайним:
route('blog.posts.show', [ 'post' => $post,]);Залежно від налаштувань і поточної мови результатом може бути:
/posts/15/en/posts/15Коли з’явилася проблема
Без route cache все працювало правильно.
Пакет
реєстрував локалізовані маршрути,
додавав middleware,
створював внутрішні route variants
і зберігав зв’язок між публічною логічною назвою та внутрішніми маршрутами Laravel.
app(LocalizedRouteRegistrar::class)->group( static function (): void { Route::get( '/posts/{post}', PostShowController::class, )->name('blog.posts.show'); },);Для режиму except_default пакет створював два варіанти маршруту:
без префікса для default locale;
із мовним префіксом для інших активних мов.
Водночас код застосунку продовжував використовувати тільки blog.posts.show.
Наступним кроком я додала тест на сумісність із Laravel route cache.
Це був не тест окремого методу і не підміна route collection. Testbench створював справжній route cache, перезавантажував Laravel application і підключав уже кешовані маршрути.
Що показав перший інтеграційний тест
Після завантаження route cache локалізована сторінка продовжувала відкриватися.
Запит до /posts/15 успішно знаходив потрібний маршрут, middleware визначав locale, а controller запускався.
Але коли controller намагався згенерувати URL для тієї самої сторінки іншою мовою, Laravel повертав помилку.
Це було найцікавіше місце проблеми.
Маршрут існував і приймав HTTP-запит, але UrlGenerator не міг знайти його за публічною логічною назвою blog.posts.show.
Incoming routing працював, а URL generation - ні.
Чому це сталося
Під час звичайної реєстрації маршрутів LocalizedRouteRegistrar виконував одразу кілька задач.
Він:
створював внутрішні локалізовані route variants;
замінював їхні Laravel route names;
додавав serializable metadata до route action;
записував відповідність між логічними та внутрішніми назвами;
запам’ятовував prefix mode і назву locale parameter;
реєстрував missing named-route resolver для UrlGenerator.
Після завантаження route cache callback із LocalizedRouteRegistrar::group() більше не виконувався.
Laravel відновив самі маршрути та збережену в них metadata. Але runtime state сервісів, який був створений під час реєстрації маршрутів, у route cache не зберігався.
У результаті після перезапуску application:
cached routes існували;
внутрішні route names існували;
route metadata існувала;
missing named-route resolver не був зареєстрований;
registrar не мав збереженого prefix mode;
registrar не мав збереженої назви locale parameter.
Що довелося змінити
1. Перенести URL resolver у service provider
Missing named-route resolver більше не повинен був залежати від виклику LocalizedRouteRegistrar::group().
Я перенесла його реєстрацію до LaravelLocalizationServiceProvider::boot(). Service provider запускається і під час звичайної роботи, і після підключення cached routes.
public function boot( Router $router, UrlGenerator $urlGenerator,): void { $urlGenerator->resolveMissingNamedRoutesUsing( static function ( BackedEnum|string $name, mixed $parameters, bool $absolute, ): ?string { return app(LocalizedRouteRegistrar::class) ->resolveLocalizedUrl( logicalName: $name, parameters: $parameters, absolute: $absolute, ); }, );}Тепер resolver доступний незалежно від того, чи виконувався callback реєстрації маршрутів у поточному application lifecycle.
2. Відновлювати конфігурацію ліниво
Раніше registrar зберігав prefix mode і route parameter у властивостях лише під час реєстрації маршрутів.
Після route cache ці властивості залишалися порожніми. Тому я винесла отримання та перевірку конфігурації в окремий метод, який використовується і під час реєстрації маршрутів, і під час генерації URL.
private function registrationConfiguration(): array{ $prefixMode = $this->prefixMode(); $routeParameter = $this->routeParameter(); $this->assertRegistrationConfiguration( prefixMode: $prefixMode, routeParameter: $routeParameter, ); return [ $prefixMode, $routeParameter, ];}3. Відновлювати route mapping із metadata
Під час створення маршрутів пакет уже додавав до route action serializable metadata.
У ній зберігалися логічна назва маршруту та тип внутрішнього variant. Laravel включив ці дані до route cache.
Після завантаження кешу LocalizedRouteNameRegistry може пройти по активній route collection і повторно побудувати mapping між публічними та внутрішніми назвами.
Що тепер перевіряють тести
Після виправлення я розширила інтеграційні тести. Вони створюють і завантажують справжній Laravel route cache, а потім перевіряють поведінку пакета вже після перезапуску application.
Тести перевіряють:
prefix mode always;
prefix mode except_default;
unprefixed URL для default locale;
prefixed URL для non-default locale;
генерацію URL за логічною route name;
explicit locale override;
генерацію поточного route URL для іншої мови;
збереження route parameters;
збереження query parameters;
відновлення route mapping із cached metadata.
Після завершення змін повний набір перевірок пакета містив 116 тестів і 189 assertions. Успішно пройшли PHPUnit, PHPStan і Laravel Pint.
Висновок
Спочатку помилка виглядала так, ніби Laravel route cache просто зламав локалізовану генерацію URL.
Насправді кеш лише показав приховану залежність у моїй архітектурі: частина поведінки пакета існувала тільки тому, що перед цим виконався callback реєстрації маршрутів.
Після виправлення відповідальність стала чіткішою:
routes зберігають serializable metadata;
registry відновлює mapping з активної route collection;
service provider реєструє resolver;
registrar отримує актуальну конфігурацію тоді, коли вона потрібна.
Route cache зберігає маршрути, але не зберігає історію того, як і за допомогою яких сервісів вони були створені.
Саме заради таких перевірок я й розробляю пакет окремо від конкретного застосунку.
Інтеграційний тест не просто знайшов помилку. Він допоміг зробити контракт пакета чіткішим і прибрати залежність, яка могла проявитися лише в production-сценарії.



