On the modern web, localization is much more than just translating words into another language. It is the foundation of a high-quality user experience (UX) and a powerful tool for search engine optimization (SEO). When a visitor arrives at oleant.dev and sees content in their native language, it's not just convenient — it’s a signal that the resource is reliable and its author deeply cares about the audience's comfort. Localization transforms your site from a local project into a global resource, opening doors to new readers and clients from different countries.
However, behind the apparent simplicity of switching languages lies a serious architectural challenge. How do you effectively manage static phrases in the interface? Where should you store dynamic blog content so that it is accessible in different languages while remaining easy to edit? How do you ensure that search engines correctly index transliterated URLs?
In this article, we will analyze how to implement full-scale multi-language support in a Laravel project using a reliable technology stack. We will rely on our current set of technologies:
-
mcamara/laravel-localization— for professional routing, managing locale prefixes in URLs, and SEO-friendly redirects. -
spatie/laravel-translatable— for flexible storage of localized content (article titles, categories) directly in the database using JSON fields. -
Filament PHP— as a powerful framework for our admin panel, where we implement convenient translation editing using tabs. This article will be the first part of a series: we will focus on the server-side and Blade templates. In the next material, we will switch to the frontend and break down the specifics of localizing Vue components. Let's get started with configuring the architecture!
Infrastructure and Routing
For full-scale localization on oleant.dev, we use a combination of settings that ensures clean URLs and SEO-friendliness.
Configuring Supported Languages
In the config/laravellocalization.php file, we define the active locales. An important point for SEO is setting hideDefaultLocaleInURL to true. This helps avoid content duplication: if de is the primary locale, then the page will be accessible at /ueber-mich, rather than /de/ueber-mich.
// config/laravellocalization.php
'supportedLocales' => [
'de' => ['name' => 'German', 'script' => 'Latn', 'native' => 'Deutsch', 'regional' => 'de_DE'],
'en' => ['name' => 'English', 'script' => 'Latn', 'native' => 'English', 'regional' => 'en_GB'],
'ru' => ['name' => 'Russian', 'script' => 'Cyrl', 'native' => 'русский', 'regional' => 'ru_RU'],
'uk' => ['name' => 'Ukrainian', 'script' => 'Cyrl', 'native' => 'українська', 'regional' => 'uk_UA'],
],
'defaultLocale' => 'de',
'hideDefaultLocaleInURL' => true,
...
Routing and Middleware
The primary work happens in routes/web.php. We wrap all routes in a Route::group, using LaravelLocalization::setLocale() as a prefix. This allows the package to dynamically determine the language from the URL.
Pay attention to the middleware order. We use localizationRedirect for SEO redirects (it automatically corrects URLs without a locale to the version with a locale) and localeViewPath for the correct selection of translation files.
// routes/web.php
Route::group([
'prefix' => LaravelLocalization::setLocale(),
'middleware' => [
'localizationRedirect',
'localeViewPath',
'cacheResponse'
]
], function () {
Route::get(LaravelLocalization::transRoute('routes.blog'), [BlogController::class, 'index'])
->name('blog.index');
// ... remaining routes
});
A critically important point: we place cacheResponse from Spatie last in the middleware stack. This guarantees that we are caching the already "localized" response. If you swap their order, you risk serving a cached German version of the page to a user with the locale [EN, RU, UK].
Automatic Language Detection
Although useAcceptLanguageHeader can be set to true, for many projects, control via session or explicit user choice is preferable. In the current configuration, we have set useAcceptLanguageHeader => false to avoid unexpected redirects for search engine bots, relying on the fact that localizationRedirect correctly handles the path structure. This provides more predictability in how the site is indexed.
Interface Localization (Static)
To manage static texts—menu elements, buttons, page headings—we use the classic Laravel approach: the resources/lang file system. This solution provides an ideal balance between performance and development convenience.
File Organization
We separate content by purpose. For example, the routes.php file is responsible for URL structure, while blog.php handles meta tags and blog texts. This simplifies finding the necessary key:
// resources/lang/en/blog.php
return [
'meta_title_index' => 'Freelancer IT Path | Reviews and Guides',
'no_posts_found' => 'No posts found.',
// ...
];
Practice in Blade
When developing the interface, we use the global helper __(). If a key is not found, Laravel returns the key itself by default, which helps in noticing missing translations immediately during the development phase.
<x-app-layout
:title="__('blog.meta_title_index')"
:description="__('blog.meta_description_index')">
<div class="text-2xl font-bold">
{{ __('blog.no_posts_found') }}
</div>
</x-app-layout>
Why is file-based localization the "gold standard"?
Using resources/lang files to store static translations is an approach verified by thousands of Laravel projects. Its popularity is not accidental: it offers a nearly ideal balance between performance and ease of maintenance.
When you store texts as PHP arrays, you achieve maximum performance. Laravel efficiently caches these files, so accessing a translation string happens almost instantaneously without burdening the database with unnecessary queries. Furthermore, this method ensures perfect version control: all project texts "travel" with the code in Git. You can always see who changed what text and when, and if necessary, roll back to a month-old translation version with a single command.
For us as developers, a huge plus is the convenience of working within an IDE. Modern editors like PhpStorm are truly "in love" with this approach: they automatically highlight used keys, allow instant navigation to the translation file via Ctrl+Click, and even display warnings if a key has not been translated somewhere.
Nevertheless, the "gold standard" has a flip side that is important to know before starting a project:
Firstly, the file-based approach requires that every single fix, even for the smallest typo, goes through a full cycle: code editing, committing, pushing, and deploying. If your site has many dynamic texts that change weekly, this process can quickly become tiresome.
Secondly, a barrier arises for non-technical specialists. If your site is managed by a content manager, they are completely dependent on you. To change a menu heading or correct a description on the homepage, they have to write a ticket to the developer every time. If your team includes people responsible for content who are not familiar with Git and the project structure, the file-based translation system will create a "glass ceiling" for them, which can only be overcome through training or by moving to more flexible solutions.
When to look at alternatives?
If the project turns into a huge ecosystem with thousands of lines of text that require "on-the-fly" editing, it is worth considering the use of databases or specialized services like Lokalise or Crowdin. They allow moving translations out of the code but add complexity to caching configuration and CI/CD processes.
Dynamic Content and Spatie Translatable
When a project goes beyond a couple of static pages, file-based localization gives way to working with the database. To effectively manage multi-language articles, we use the combination of Eloquent + Spatie Translatable.
Architecture: The DB as a Master System
Instead of creating cumbersome tables like posts_translations, we store all translations directly in the main posts table in JSON-type fields. This makes data retrieval as fast as possible (no unnecessary JOINs) and is natural for Laravel. In the Post model, we include the HasTranslations trait and mark the necessary fields:
use Spatie\Translatable\HasTranslations;
class Post extends Model
{
use HasTranslations;
public array $translatable = [
'title', 'content', 'seo_title', 'slug_i18n',
];
}
SEO-friendly URL: Automating Transliteration
One of the main tasks is creating clean URLs for each locale (e.g., /blog/kak-vyuchit-laravel for Russian and /blog/learning-laravel for English). We solve this via the slug_i18n JSON field and reactive forms in Filament. When filling in the title in the admin panel, we generate the slug "on the fly":
// In Filament Resource
Forms\Components\TextInput::make('title.' . $localeName)
->live(onBlur: true)
->afterStateUpdated(function ($state, Forms\Set $set) use ($localeName) {
// Automatic slug generation when entering the title
$set("slug_i18n.{$localeName}", Str::slug($state));
}),
The Magic of Clean HTML
When we abandon heavy "standard" solutions, such as distributing translations across dozens of auxiliary tables or implementing bulky packages, we don't just simplify the database structure—we change the philosophy of working with content.
The transparency of the system is achieved because the model remains predictable. In a standard Laravel application, you work with a $post object by calling the $post->title method, and this familiar syntax is preserved in your approach. Laravel automatically "picks up" the current locale via app()->getLocale(), sparing you from the need to write translation-selection logic in every controller or template. All the complexity associated with storing multiple languages in a single database field is hidden "under the hood" of the trait from Spatie, and your code remains clean and free of unnecessary calls. This is not just saving characters; it is reducing the probability of errors that inevitably arise when business logic becomes overloaded with technical details of language switching.
The flexibility of the solution stems directly from its integration with Filament. Instead of forcing the editor to jump across different screens or get lost in complex forms, the system collects all language versions within a single record, distributing them into convenient tabs. This visual approach turns administration into an intuitive process: you see the content in different languages as a single whole, which allows you to instantly compare versions, maintain the structure, and ensure that not a single heading or piece of text is forgotten. The admin panel ceases to be just a data input form and becomes a professional tool that sets quality standards for any text that ends up on the site.
Security, styling, shortcode parsing, or specific transliteration for the German language, taking all umlauts into account—all this is in your hands. By changing the logic in one place, you influence the entire application while remaining confident that perfectly prepared content will always reach the frontend, regardless of what data was originally entered.
Thus, you haven't just implemented multi-language support—you have turned it into a natural attribute of the model. By getting rid of infrastructure debt in the form of dozens of extra tables, complex migrations, and "heavy" JOIN queries, you have gained a system that scales along with your tasks while remaining fast, elegant, and fully controllable.
Admin Panel: Ease of Management
There comes a moment when architecture meets reality. This is precisely where it is decided whether your admin panel will be a "black box" or an effective tool that doesn't cause frustration during daily work. The Filament-based admin panel is the facade of your application, and its ergonomics determine how comfortably you will be able to develop your project. When we talk about multi-language support, the primary task is to ensure that changing the locale in the admin panel is not a technical operation, but a natural continuation of the workflow. Setting the default locale to DE (German) is the first step toward ensuring the interface immediately aligns with your priorities, reducing the number of unnecessary switches.
Implementing tabs within forms is a key architectural decision that radically changes the user experience. Instead of creating long "sheets" of fields where translations are mixed together, we group them into logical blocks using Tabs components. When an editor opens a category card, they see structured tabs, each responsible for its own language. This eliminates visual clutter and allows for focusing on a specific locale while keeping the context of the entire entity in view. Take a look at how this concept is implemented in CategoryResource:
public static function form(Form $form): Form
{
return $form->schema([
Forms\Components\Hidden::make('slug'), // System slug
Tabs::make(__('filament.resources.categories.form.translations_tab'))
->tabs(function () {
$tabs = [];
foreach (config('app.locales') as $locale) {
$tabs[] = Tab::make(strtoupper($locale))
->schema([
Forms\Components\TextInput::make('name.' . $locale)
->label(__('filament.resources.categories.form.name_label') . ' (' . $locale . ')')
->required()
->live(onBlur: true)
->afterStateUpdated(function (string $operation, $state, Forms\Set $set) use ($locale) {
$set('slug_i18n.' . $locale, Str::slug($state));
if ($operation === 'create' && $locale === config('app.locale')) {
$set('slug', Str::slug($state));
}
}),
Forms\Components\TextInput::make('slug_i18n.' . $locale)
->label('Slug (' . $locale . ')')
->required()
->unique(table: 'categories', column: 'slug_i18n->' . $locale, ignoreRecord: true),
Forms\Components\TextInput::make('title.' . $locale)
->label(__('filament.resources.categories.form.title_label') . ' (' . $locale . ')'),
Forms\Components\Textarea::make('description.' . $locale)
->label(__('filament.resources.categories.form.description_label') . ' (' . $locale . ')'),
]);
}
return $tabs;
})
->columnSpan('full'),
]);
}
This approach turns the data entry process into a predictable pipeline. Using live(onBlur: true) along with afterStateUpdated allows the system to automatically generate slugs "on the fly" as soon as the manager enters a category name. At the same time, the logic inside afterStateUpdated strictly separates the local slug (slug_i18n) for a specific language from the main system slug (slug), ensuring full control over the site's SEO structure. Thanks to the dynamic loop over config('app.locales'), this form is easily scalable: when adding a new language, there is no need to change the resource code, as Filament automatically deploys the necessary fields for translation.
Ultimately, an intuitive admin panel is one where the manager does not need to read instructions. Visual tab markers, automated transliteration, and strict validation make content management a background task, minimizing the likelihood of errors. You are creating not just an interface, but a tool that guides the user itself, guaranteeing data integrity and high performance—which is the main goal of a quality administrative panel.
Language Switcher and Seamless Navigation
The final stage of creating a multi-language infrastructure is the frontend component, which transforms architectural abstractions into a user-friendly interface. The language switcher is not just a dropdown list; it is the link between the user, the current URL, and the database. In the oleant.dev project, the implementation of this component relies on the mcamara/laravel-localization package, which ensures proper management of locale prefixes in URLs, making the site structure predictable for both visitors and search engines.
The core of the language-dropdown component is the logic for dynamic link generation. The use of LaravelLocalization::getLocalizedURL guarantees that the user will always land on the appropriate page, whether it is the homepage or a highly specialized post. Special attention is paid here to handling "alternate slugs":
@foreach (LaravelLocalization::getSupportedLocales() as $localeCode => $properties)
@php
$routeName = Route::currentRouteName();
$url = null;
// If this is a page with localized slugs
if (isset($alternateSlugs) && is_array($alternateSlugs) && isset($alternateSlugs[$localeCode])) {
$url = LaravelLocalization::getLocalizedURL(
$localeCode,
route($routeName, ['slug' => $alternateSlugs[$localeCode]], false)
);
} else {
$url = LaravelLocalization::getLocalizedURL($localeCode, null, [], false);
}
@endphp
<!-- ... link using $url ... -->
@endforeach
This implementation solves a classic problem of multi-language sites — linking specific content to its translations. Instead of redirecting the user to the homepage when switching languages, the component "intelligently" inserts the necessary slug (alternateSlugs) corresponding to the selected locale. This creates a sense of seamlessness: while reading an article, the user can switch to the English or German version with one click while remaining within the context of the same material.
The issue of handling scenarios where a translation is missing (fallback) is resolved at the route and controller architecture level. If there is no data in the alternateSlugs array for the selected language, the system either redirects the user to the "clean" locale URL (fallback to the default version) or, with proper controller configuration, returns a 404 or a message that the article is only available in other languages. This is critically important for SEO: search bots understand the link structure thanks to hreflang meta tags, which, in conjunction with this switcher, create a complete map of the site's language versions.
Ultimately, using Alpine.js to manage the state of the dropdown list (x-data="{ open: false }") makes the interface lightweight and responsive without overloading the frontend with unnecessary requests. This component is the assembly point of the entire system: it takes the translations accumulated in the database, accounts for the slug transliteration logic, and provides the user with a simple and reliable way to navigate, completely hiding the technical complexity happening under the hood.
Architecture for Growth
Creating a multi-language project is always a balancing act between flexibility for the editor and performance for the end user. We have traveled the path from designing the database structure using JSON fields to creating a convenient admin panel in Filament and an intelligent language-switching system on the frontend. The resulting architecture turned out to be "lightweight": the absence of unnecessary JOIN queries and the use of native Laravel mechanisms allow the application to work quickly even with a large number of language versions.
Performance and Caching
However, when implementing localization, it is important to keep the "pitfalls" of caching in mind. If you are using packages like spatie/laravel-responsecache, be extremely careful: by default, the cache may not take into account the Accept-Language header or the current locale prefix. This threatens a critical error where a user from Germany might see a cached version of the page in Russian.
To solve this task, it is necessary to:
-
Isolate the cache: Ensure that the cache key (
cache_key) includes the current locale identifier(app()->getLocale()). -
Configure Varnish/Nginx correctly: If you use heavy caching at the server level, be sure to pass the Vary:
Accept-Languageheader so that caching proxy servers understand that the page content depends on the selected language. - Clearing: When updating one language version of a post via the admin panel, the system should intelligently clear the cache only for the affected URLs, rather than "wiping out" the entire site cache. (More about caching and switching to Redis can be found in the article "Engineering: Performance Architecture in Laravel with Redis")
Next Step: Vue and Frontend
Now that the backend infrastructure is running like clockwork, it becomes obvious that static localization in Blade is only half the battle. In modern Laravel applications, a significant portion of the interface falls on Vue components. How do you pass data from a localized model directly to Inertia.js? How do you avoid loading unnecessary translation dictionaries, and how do you ensure that the JavaScript frontend "understands" the same localization structure as the server?
In the next article, we will break down the "holy grail" of the multi-language frontend: integrating localization into Vue components. We will show how to pass only the necessary translations via Inertia and why the separation of responsibilities between the server (which provides content) and the client (which manages interactivity) is the only path to creating truly fast and scalable applications.
This architecture is just the beginning. Now you have a reliable framework onto which you can "attach" any tasks, confident that the foundation will withstand any load.
Top comments (0)