snowcap/i18n-bundle
Internationalization bundle for PHP applications that centralizes locale and translation handling, with configuration-driven setup for managing multiple languages across your app. Integrates with the Snowcap bundle ecosystem.
First, a reality check — this is not a Laravel package. snowcap/i18n-bundle is a Symfony bundle (last release July 2015, 1 star, 0 dependents, no maintainer activity for a decade). You cannot composer require it into a Laravel app: its composer.json pins Symfony 2.x components (symfony/symfony, symfony/framework-bundle) that will collide with the symfony/* versions Illuminate pulls in, and there is no Illuminate\Support\ServiceProvider anywhere in the tree. Treat it as reference material to read, not code to install.
Minimal steps to actually get value from it:
composer require --dev into a throwaway Symfony skeleton) so you can read it without poisoning your app's composer.lock:
git clone https://github.com/snowcap/SnowcapI18nBundle /tmp/i18n-ref
SnowcapI18nBundle.php (the bundle class) — tells you which container extensions and compiler passes are registered.DependencyInjection/Configuration.php + DependencyInjection/*Extension.php — the full config surface and defaults. This is the closest thing to a "package README".Resources/config/services.xml (or .yml) — the actual service graph: locale listeners, translation loaders, route decorators.EventListener/ (or EventListener/RequestListener.php) — how the locale is resolved per request.Resources/translations/ — what message domains and file formats it expects.getLocale, setLocale, kernel.request, and TranslatorInterface to find the three or four moving parts.Accept-Language, a session key, a URL prefix, or a user column, and in what priority? That single listener is usually the only piece worth reimplementing.If you only want a working i18n setup today, skip the bundle entirely and use mcamara/laravel-localization (routes) + Laravel's built-in translator (messages). Come back to the Snowcap source only when you need to understand why someone would put the locale in session vs. URL — the bundle's listener chain is a decent worked example of the tradeoffs.
Because you cannot run the bundle under Laravel, "implementation" means porting its patterns into idiomatic Laravel. Below are the four patterns the bundle encodes and their Laravel equivalents.
1. Locale resolution as request middleware. The bundle resolves the locale before the controller runs. Mirror that with a middleware registered in bootstrap/app.php (Laravel 11) or app/Http/Kernel.php:
final class SetLocale
{
public function __construct(private array $supported) {}
public function handle(Request $request, Closure $next): Response
{
$locale = $request->session()->get('locale')
?? $this->fromHeader($request)
?? config('app.fallback_locale');
if (! in_array($locale, $this->supported, true)) {
$locale = config('app.fallback_locale');
}
app()->setLocale($locale);
Carbon::setLocale($locale);
return $next($request);
}
}
Note ordering: session → Accept-Language → fallback is the bundle's rough priority; keep it, because URL-prefix locale (see below) should win before session. Register the URL-aware variant first and let it short-circuit.
2. Locale in the URL for SEO. The bundle's routing integration exists because locale prefixes beat session switching for indexable content. In Laravel, don't fight the router — wrap it:
Route::group(['prefix' => '{locale}', 'where' => ['locale' => 'en|nl|de']], function () {
Route::get('/', HomeController::class)->name('home');
Route::get('/products/{product}', [ProductController::class, 'show'])->name('products.show');
});
Then a UrlDefaults middleware calling URL::defaults(['locale' => app()->getLocale()]) so route('home') works without passing the parameter everywhere. Guard against missing locale with a fallback route group outside the prefix, or a redirect route.
3. A translation loader backed by storage. The bundle's loader/compiler wiring is the seed for a database-backed translator — useful when marketing edits copy without a deploy. Register a custom loader with Laravel's translator in a service provider:
// app/Providers/TranslationServiceProvider.php
public function boot(): void
{
$this->app['translation.loader']->addLoader('db', $this->app->make(DbLoader::class));
}
// config/app.php or a dedicated config/translations.php
'loader' => ['db' => ['database', 'translations']],
final class DbLoader implements LoaderInterface
{
public function load($locale, $group, $namespace = null): array
{
return Cache::remember("i18n:{$locale}:{$group}", 600, fn () => Translation::query()
->where('locale', $locale)
->where('group', $group)
->pluck('value', 'key')
->all());
}
public function addNamespace($namespace, $hint) {}
public function namespaces(): array { return []; }
}
Merge DB results over file results (array_replace(parent::load(...), $dbRows)) so files remain the safe baseline and the DB only overrides. Warm the cache on write via an observer; never let a request miss on cold cache and hit the DB per string.
4. Locale switching endpoint. The bundle exposes a switch route that writes the locale and redirects back. Keep that and make it explicit rather than implicit:
Route::post('/locale/{locale}', function (string $locale) {
abort_unless(in_array($locale, config('app.supported_locales'), true), 404);
session(['locale' => $locale]);
return back();
})->middleware('throttle:30,1')->name('locale.switch');
Workflows that pay off
config/app.php-adjacent supported_locales array as the single source of truth for middleware, the switch route, and the where() constraint. Hard-coded three-way duplication is the most common drift bug.Accept-Language strings (nl-NL,nl;q=0.9,en;q=0.5).spatie/laravel-translatable rather than imitating the bundle's entity translation — that package is maintained and covers the case.symfony/symfony: ~2.3, PHP >=5.3.3 era) conflict with Laravel's symfony/http-foundation|translation|routing requirements. If you genuinely need to inspect it running, use a separate Symfony 2.8 skeleton — do not try composer require in your app and then --ignore-platform-reqs, or you'll spend an afternoon resolving transitively downgraded Symfony components.$request->getSession()->get(...), getContainer()->getParameter(...), XML service definitions with %locale% parameters, and kernel.request listeners with priority integers. In Laravel: $request->session(), injected config, plain PHP service providers, and middleware ordering. Don't transliterate the container-aware style — it fights Laravel's DI.App::setLocale() is per-request in PHP-FPM but persists across requests under Octane/Swoole. If you port the middleware, reset in a terminate() or use Octane's sandboxed globals; otherwise a Dutch visitor's locale bleeds into the next request served by that worker.Accept-Language parsing is deceptively hard. The bundle-era regexes miss q=0 (explicit reject), wildcards, and script subtags (zh-Hant-TW). Use symfony/http-foundation's Request::getPreferredLanguage($locales) — it's already in your vendor dir and handles the common cases correctly.app.fallback_locale; the bundle-era behaviour often returned the raw key. Decide explicitly and test it: __('products.empty') returning products.empty on screen is a shipping bug, not a feature.|-pipe plural syntax. Laravel uses | too, but trans_choice ranges ({0} none|{1} one|[2,*] many) can trip you up. Dates/numbers: set Carbon::setLocale() and use NumberFormatter for currency rather than number_format.Cache::forget per locale/group, or a versioned key i18n:v{N}:{locale}:{group} bumped on save). Tag-based invalidation only works on Redis/Memcached/array — not the file or database cache drivers most apps start with.translator service's addLoader, the kernel.request listener priority, and the routing decorator/URL::defaults equivalent. Those three are where nearly all behaviour lives; everything else is glue.app()->getLocale() at the top of the middleware, inside the controller, and in a Blade view. The bundle's listener chain has the same three layers; if the value changes mid-request, something downstream is calling setLocale() — commonly a Carbon::setLocale() side effect or a queued job bootstrapping a fresh app.mcamara/laravel-localization, spatie/laravel-translatable) for everything else.How can I help you explore Laravel packages today?