0% прочитано

How route cache broke URL generation in my custom Laravel localization package

While building my own Laravel localization package, I added stable logical route names and URL generation for another locale. Everything worked without caching, but after route:cache the pages still opened while URL generation stopped working. This article explains the cause and the architectural fix.

31 липня 2026 р. 7 хв читанняLaravel
This is a practical case from building my own Laravel localization package. Everything worked correctly until I added an integration test with a real route cache.

Why I started building my own localization package

I am building my own Laravel localization package that I can reuse across different products.

Existing packages solve common localization tasks well, but my main requirements were slightly different. I needed more than translated text or a locale prefix in the URL. I needed a separate infrastructure layer for managing product locales.

I did not want the package to own the locale list through its own configuration.

In a real product, locales may be managed through a database, an administration interface, tenant settings, or an external service. The package should know how to work with a locale, but it should not decide where that locale comes from.

The main package requirements were:

  • the locale list belongs to the product, not the package;

  • the locale source can be replaced without changing routing logic;

  • the default locale is defined by the product;

  • disabled locales do not participate in routing;

  • URLs may include a prefix for every locale;

  • the default locale may use URLs without a prefix;

  • application code uses stable route names;

  • the package can generate the current URL for another locale;

  • localized routes remain compatible with Laravel route cache.

The main package feature

The product supplies its available locales through the LocaleProvider contract.

The package works with an already validated locale collection and does not depend on whether those locales came from configuration, a database, or another source.

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

Another important feature is stable logical route names.

Application code should not know which internal route variants the package created for different locales. Regardless of the active locale, the application continues to use a standard route call:

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

Depending on the configuration and active locale, the result may be:

php
/posts/15/en/posts/15
Application code works with the stable blog.posts.show name, while internal localized route names remain a package implementation detail.

Where the problem appeared

Without route cache, everything worked correctly.

The package

  • registered localized routes,

  • attached middleware,

  • created internal route variants,

  • and stored the relationship between the public logical name and Laravel's internal routes.

php
app(LocalizedRouteRegistrar::class)->group(    static function (): void {        Route::get(            '/posts/{post}',            PostShowController::class,        )->name('blog.posts.show');    },);

In except_default mode, the package created two route variants:

  • an unprefixed route for the default locale;

  • a prefixed route for other enabled locales.

Application code still used only blog.posts.show.

The next step was adding a Laravel route cache compatibility test.

This was not a unit test of one method or a mocked route collection. Testbench created a real route cache, reloaded the Laravel application, and loaded the cached routes.

What the first integration test revealed

After the route cache was loaded, the localized page still opened correctly.

A request to /posts/15 found the correct route, the middleware resolved the locale, and the controller executed.

But when the controller tried to generate the same page URL for another locale, Laravel returned an error.

Route [blog.posts.show] not defined.

That was the most interesting part of the problem.

The route existed and accepted an HTTP request, but UrlGenerator could not find it through the public blog.posts.show logical name.

Incoming routing worked, but URL generation did not.

Why it happened

During ordinary route registration, LocalizedRouteRegistrar performed several responsibilities.

It:

  • created internal localized route variants;

  • replaced their Laravel route names;

  • attached serializable metadata to each route action;

  • stored mappings between logical and internal route names;

  • remembered the prefix mode and locale parameter name;

  • registered a missing named-route resolver for UrlGenerator.

After route cache loading, the LocalizedRouteRegistrar::group() callback no longer executed.

Laravel restored the routes and their stored metadata. However, route cache did not restore the runtime state of the services that had participated in route registration.

As a result, after the application restarted:

  • cached routes existed;

  • internal route names existed;

  • route metadata existed;

  • the missing named-route resolver was not registered;

  • the registrar had no stored prefix mode;

  • the registrar had no stored locale parameter name.

Route cache stores routes, but it does not store the runtime state of the services that registered them.

What I had to change

1. Register the URL resolver in the service provider

The missing named-route resolver could no longer depend on LocalizedRouteRegistrar::group().

I moved its registration to LaravelLocalizationServiceProvider::boot(). The service provider runs both during normal application startup and after cached routes are loaded.

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,                );        },    );}

The resolver is now available regardless of whether the route registration callback executed during the current application lifecycle.

2. Resolve routing configuration lazily

Previously, the registrar stored the prefix mode and route parameter in properties only during route registration.

After route cache loading, those properties remained empty. I moved configuration resolution and validation into a shared method used during both route registration and URL generation.

php
private function registrationConfiguration(): array{    $prefixMode = $this->prefixMode();    $routeParameter = $this->routeParameter();     $this->assertRegistrationConfiguration(        prefixMode: $prefixMode,        routeParameter: $routeParameter,    );     return [        $prefixMode,        $routeParameter,    ];}

3. Restore route mappings from metadata

When routes were created, the package already attached serializable metadata to each route action.

The metadata contained the logical route name and the internal variant type. Laravel included that information in the route cache.

After cached routes are loaded, LocalizedRouteNameRegistry can inspect the active route collection and rebuild the mapping between public and internal route names.

URL generation initialization no longer depends on the route registration callback running again.

What the tests now cover

After the fix, I expanded the integration tests. They create and load a real Laravel route cache and then verify package behavior after the application has restarted.

The tests cover:

  • always prefix mode;

  • except_default prefix mode;

  • an unprefixed URL for the default locale;

  • a prefixed URL for a non-default locale;

  • URL generation through a logical route name;

  • an explicit locale override;

  • current-route URL generation for another locale;

  • preserved route parameters;

  • preserved query parameters;

  • route mapping restoration from cached metadata.

After the changes, the full package test suite contained 116 tests and 189 assertions. PHPUnit, PHPStan, and Laravel Pint all passed successfully.

Conclusion

At first, the error looked as though Laravel route cache had simply broken localized URL generation.

In reality, the cache exposed a hidden dependency in my architecture: part of the package behavior existed only because a route registration callback had previously executed.

After the fix, the responsibilities became clearer:

  • routes store serializable metadata;

  • the registry restores mappings from the active route collection;

  • the service provider registers the resolver;

  • the registrar reads the current configuration when it is needed.

Route cache stores routes, but it does not store the history of how and through which services those routes were created.

This is exactly why I am developing the package separately from a specific application.

The integration test did more than find a bug. It helped make the package contract clearer and removed a dependency that could otherwise appear only in a production scenario.