Laravel 13 Localization: Translations, Bilingual Routes, and Multi-Language Step by Step
A bilingual blog multiplies its audience, and Laravel 13 ships localization out of the box with two ways to store translations. This very ES/EN site is the proof the setup works: here is the complete i18n flow, from the __() helper to /es and /en routes.
What Localization in Laravel 13 Is and Where to Start
Localization in Laravel 13 is the framework's system for displaying strings in several languages without touching your application logic. Instead of hardcoding text in views, you use keys that resolve to the active locale on each request. It is the foundation of any multilingual project, and Laravel 13 keeps it stable in the core, presented as part of a stack built "for Artisans and agents".
The Two Ways to Store Translations: Lang Files and JSON
Laravel offers two complementary mechanisms. The first is language files with keys: one directory per language and one file per domain (for example, lang/es/messages.php), where each key returns its text. The second is JSON translations: a single lang/es.json that maps the original string to its translation, ideal for loose strings that do not have their own key.
Publishing the Lang Directory with Artisan
The Laravel 13 skeleton does not include the lang directory by default, so the first step is generating it. Run php artisan lang:publish and the framework creates the base structure with lang/en.json and an example file per language. From there, add your languages: for Spanish, create lang/es.json and the lang/es/ directory.
First Translations with the __() Helper
The __() helper is the gateway to localization: it retrieves a translation by its key, taking the active locale into account.
Keys, Per-Language Files, and Fallback Locale
In a keyed file you define key-value pairs: 'welcome' => 'Welcome'. In the view you use __('messages.welcome'), where messages is the file and welcome the key. The framework resolves the key in the request language and, if it is missing, falls back to the locale defined in config/app.php. With JSON files, the key is the original text itself: __('Hola') looks up "Hola" in lang/en.json.
Parameters and Placeholders (:name)
Translations are rarely static, which is why the helper supports placeholders. Define 'welcome_user' => 'Welcome, :name' and call it with __('messages.welcome_user', ['name' => $user->name]). The placeholder is replaced at runtime, so the same key works for any user.
Pluralization with {0}, {1}, and [2,*]
Plural rules differ between languages, and Laravel handles it with ranges. Define 'apples' => '{0} There are no apples|{1} There is one apple|[2,*] There are :count apples' and use trans_choice('messages.apples', $count, ['count' => $count]). Each language declares its own ranges, so plurals stay correct everywhere.
JSON Translations: Loose Strings and Packages
JSON translations come into play when you do not control the key, especially with strings from packages or third-party libraries. Any string the framework cannot find as a key is looked up in lang/{locale}.json. This lets you translate texts like "Login" or "Reset Password" from vendor code without touching it: add the entry to the JSON and the package now shows your translation. It is also the fast path for translating the Laravel core itself when you use a language package.
Switching the Language: Middleware and app()->setLocale()
With translations in place, the remaining question is how the language is chosen on each request. The key piece is app()->setLocale(), which sets the active locale, and the right place to call it is a middleware.
Why Middleware and Not the Service Provider
The AppServiceProvider runs before the session is available and before the authenticated user exists, so you cannot read the stored language there. A middleware, on the other hand, runs after the session has started and can read the locale from the session, the URL, or the user. It is the standard pattern in multilingual Laravel apps: the middleware decides, and the rest of the app only consumes the already-set locale.
Browser Language Detection (Accept-Language)
To guess the visitor's language, read the Accept-Language header inside the middleware and pick the first language your app supports. It is a good starting point for the first visit, combined with a visible language switcher that stores the choice in the session for the next requests.
Localized Routes: /es and /en
For a blog or a content website, the URL should reflect the language. Localized routes with a prefix are the recommended option, also from an SEO standpoint.
Route Groups with a {locale} Prefix
Group your routes under a dynamic prefix and validate it: Route::prefix('{locale}')->where('locale', 'en|es')->group(function () { ... }). Inside the group, a middleware reads the {locale} parameter and calls app()->setLocale($locale). The where validation keeps nonexistent routes from falling into the group and lets you keep unprefixed routes outside (for example, a redirect to the localized home).
Alternative: mcamara/laravel-localization
If you prefer not to build the mechanism by hand, the mcamara/laravel-localization package adds browser language detection, smart routing (define your routes once and serve them in every language), and locale prefix middleware. It is the most widely used option in production and speeds up the initial setup.
Generating URLs with the Active Prefix
With prefixes, links must be generated with the correct language. With the {locale} group, pass the locale as a parameter: route('home', ['locale' => app()->getLocale()]). With mcamara, the localized_route() helper does the job. A language switcher in Blade is then a pair of links to the same route with different prefixes.
Database-Driven Translations with spatie/laravel-translation-loader
Lang files require a deployment for every text change, which is not always acceptable on a blog. The spatie/laravel-translation-loader package stores translations in the database with caching, so an editor can change a string without touching code. You register the loader as the translation driver and the __() helper keeps working exactly the same: only the data source changes. It is the natural path when content is managed by a non-technical team.
Speed Up with laravel-lang/lang: Ready-Made Translations
There is no point translating the core keys by hand. The laravel-lang/lang package provides ready-made translations for dozens of languages, including auth, pagination, validation, and the rest of Laravel's domains. Install it, publish the languages you need, and your app immediately shows correctly translated validation and pagination messages, freeing your team to focus on your own content.
Multilingual SEO: hreflang, Canonical, and Sitemap
Publishing in two languages without SEO preparation looks like duplicate content to Google. The solution has three parts. First, every page declares its alternatives with link rel="alternate" hreflang="es" and hreflang="en" tags, plus hreflang="x-default". Second, each version's canonical points to its own localized URL, not the unprefixed one. Third, the sitemap includes every URL with its language as independent entries. With those three pieces, Google understands these are versions of the same content rather than duplicates, and serves each user their language.
Conclusion
Localization in Laravel 13 is mature and solvable with the core alone: lang files or JSON, the __() helper, a middleware that sets the locale, and routes with an /es and /en prefix. Packages from Spatie, mcamara, and laravel-lang cover the advanced cases, and the hreflang-canonical-sitemap trio closes the loop for SEO. This ES/EN blog is the proof the flow works in production. Keep reading for more Laravel web development guides.