Icanup
0% прочитано

Як route cache зламав генерацію URL у власному Laravel-пакеті локалізації

Під час розробки власного Laravel-пакета локалізації я додала стабільні логічні назви маршрутів і генерацію URL для іншої мови. Без кешу все працювало, але після route:cache сторінки відкривалися, а генерація URL перестала працювати. Розбираю причину та архітектурне рішення.

31 липня 2026 р. 7 хв читанняЛаравель
Це практичний кейс із розробки мого власного Laravel-пакета локалізації. Усе працювало правильно, доки я не додала інтеграційний тест зі справжнім route cache.

Чому я взялася за власний пакет локалізації

Я працюю над власним Laravel-пакетом локалізації, який зможу повторно використовувати в різних продуктах.

Готові пакети добре вирішують типові задачі, але в моєму випадку основні вимоги були трохи іншими. Мені був потрібен не просто спосіб перекласти текст або додати мовний префікс до URL, а окремий інфраструктурний шар для роботи з мовами продукту.

Я не хотіла, щоб пакет сам зберігав перелік мов у власній конфігурації.

У реальному продукті мови можуть керуватися через базу даних, адміністративну панель, налаштування окремого клієнта або зовнішній сервіс. Тому пакет має знати, як працювати з locale, але не повинен вирішувати, звідки ця locale з’явилася.

Основні вимоги до пакета:

  • перелік мов належить продукту, а не пакету;

  • джерело мов можна замінити без зміни routing-логіки;

  • default locale визначається продуктом;

  • вимкнені мови не беруть участі в маршрутизації;

  • підтримуються URL із префіксом для всіх мов;

  • підтримуються URL без префікса для default locale;

  • application code використовує стабільні назви маршрутів;

  • пакет може згенерувати поточний URL для іншої мови;

  • локалізовані маршрути мають працювати з Laravel route cache.

Головна особливість пакета

Продукт передає доступні мови через контракт LocaleProvider.

Сам пакет працює з уже перевіреною колекцією мов і не залежить від того, чи були вони отримані з config, бази даних або іншого джерела.

php
interface LocaleProvider{    public function locales(): LocaleCollection;}

Ще одна важлива особливість - стабільні логічні назви маршрутів.

Код застосунку не повинен знати, які внутрішні route variants створив пакет для різних мов. Незалежно від поточної locale виклик залишається звичайним:

php
route('blog.posts.show', [    'post' => $post,]);

Залежно від налаштувань і поточної мови результатом може бути:

php
/posts/15/en/posts/15
Application code працює зі стабільною назвою blog.posts.show, а внутрішні назви локалізованих маршрутів залишаються деталлю реалізації пакета.

Коли з’явилася проблема

Без route cache все працювало правильно.

Пакет

  • реєстрував локалізовані маршрути,

  • додавав middleware,

  • створював внутрішні route variants

  • і зберігав зв’язок між публічною логічною назвою та внутрішніми маршрутами Laravel.

php
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 повертав помилку.

Route [blog.posts.show] not defined.

Це було найцікавіше місце проблеми.

Маршрут існував і приймав 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.

Route cache зберігає маршрути, але не зберігає runtime state сервісів, які брали участь у їх реєстрації.

Що довелося змінити

1. Перенести URL resolver у service provider

Missing named-route resolver більше не повинен був залежати від виклику LocalizedRouteRegistrar::group().

Я перенесла його реєстрацію до LaravelLocalizationServiceProvider::boot(). Service provider запускається і під час звичайної роботи, і після підключення cached routes.

php
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.

php
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 між публічними та внутрішніми назвами.

Ініціалізація URL generation більше не залежить від повторного виконання route registration callback.

Що тепер перевіряють тести

Після виправлення я розширила інтеграційні тести. Вони створюють і завантажують справжній 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-сценарії.