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.
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:
route('blog.posts.show', [ 'post' => $post,]);Depending on the configuration and active locale, the result may be:
/posts/15/en/posts/15Where 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.
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.
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.
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.
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.
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.
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.



