Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 092 / 100

Laravel localization: a PHP & Laravel guide for developers

Multi-language apps.

Expona Admin9 min read

Laravel localization: a PHP & Laravel guide for developers

Building applications that serve users across different countries and languages is table stakes for modern web platforms. Laravel localization makes this straightforward by providing a structured system to manage translations, switch between locales, and handle pluralization rules automatically. In this guide, we'll walk through the patterns that production teams use to ship multi-language applications without creating maintenance headaches.

Translation files in lang/

Laravel stores translations in the lang/ directory at your project root. Each supported language gets its own folder, and within that folder, you organize strings by domain using PHP files that return associative arrays.

Let's say we're building a SaaS platform that supports English and Spanish. Here's the directory structure:

php
lang/
├── en/
│   ├── messages.php
│   ├── auth.php
│   └── validation.php
└── es/
    ├── messages.php
    ├── auth.php
    └── validation.php

Each file contains key-value pairs. For example, lang/en/messages.php:

php
<?php

return [
    'welcome' => 'Welcome to our platform',
    'dashboard_title' => 'Your Dashboard',
    'export_report' => 'Export Report',
    'user_joined' => 'User joined successfully',
];

And the Spanish equivalent, lang/es/messages.php:

php
<?php

return [
    'welcome' => 'Bienvenido a nuestra plataforma',
    'dashboard_title' => 'Tu Panel',
    'export_report' => 'Exportar Reporte',
    'user_joined' => 'Usuario se unió exitosamente',
];

The folder structure keeps translations organized and scalable. When you add a new language, you simply create a new folder and duplicate the file structure. Teams we've worked with maintain this discipline strictly—it prevents orphaned keys and makes code reviews easier.

You can also create nested structures for more complex applications:

php
lang/
├── en/
│   ├── messages.php
│   ├── emails/
│   │   ├── confirmation.php
│   │   └── reset_password.php
│   └── admin/
│       └── dashboard.php

Access nested translations using dot notation: __('emails.confirmation.subject').

Using __() and trans_choice for plurals

The __() helper function retrieves a translation key and is the primary tool for rendering strings in your application. It accepts the key name and optional parameters for interpolation.

In a Blade template:

php
<h1>{{ __('messages.welcome') }}</h1>
<p>{{ __('messages.dashboard_title') }}</p>

With parameter substitution:

php
<?php
// lang/en/messages.php
return [
    'greeting' => 'Hello, :name!',
    'order_status' => 'Your order #:id is :status',
];

// In controller or template
echo __('messages.greeting', ['name' => 'Alice']);
// Output: Hello, Alice!

echo __('messages.order_status', ['id' => 1234, 'status' => 'shipped']);
// Output: Your order #1234 is shipped

For pluralization, Laravel provides trans_choice(), which evaluates a count and selects the correct form. This is critical because plural rules vary by language—English has two forms (singular/plural), but other languages may have three or more.

Define a pluralizable key using the pipe character:

php
<?php
// lang/en/messages.php
return [
    'items_in_cart' => '{0} Your cart is empty|{1} You have one item in your cart|[2,*] You have :count items in your cart',
];

// lang/es/messages.php
return [
    'items_in_cart' => '{0} Tu carrito está vacío|{1} Tienes un artículo en tu carrito|[2,*] Tienes :count artículos en tu carrito',
];

Use trans_choice() in your code:

php
<?php
$count = auth()->user()->cart()->items()->count();
echo trans_choice('messages.items_in_cart', $count, ['count' => $count]);

// If count = 0: Your cart is empty
// If count = 1: You have one item in your cart
// If count = 5: You have 5 items in your cart

The range syntax [2,*] means "2 or more." You can define multiple ranges for languages with complex plural rules. This ensures your UI reflects grammatically correct strings for every user.

Locale per user or route

Setting the locale is the mechanism that tells Laravel which language directory to pull from when resolving translation keys. You can set it globally, per-user, or per-route depending on your architecture.

Global locale in config/app.php:

php
<?php
// config/app.php
return [
    'locale' => 'en',
    'fallback_locale' => 'en',
    'supported_locales' => ['en', 'es', 'fr', 'de'],
];

The fallback locale is used when a translation key doesn't exist in the current locale—always set this to your primary language.

Per-user locale: Store the user's preferred language in the database and set it when they authenticate:

php
<?php
// app/Http/Middleware/SetUserLocale.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class SetUserLocale
{
    public function handle(Request $request, Closure $next)
    {
        if (auth()->check()) {
            $locale = auth()->user()->locale ?? app()->getLocale();
            app()->setLocale($locale);
        }

        return $next($request);
    }
}

Register this middleware in app/Http/Kernel.php under the web middleware group so it runs on every request.

Per-route locale: For public-facing pages, you might accept a locale segment in your URL:

php
<?php
// routes/web.php
Route::group(['prefix' => '{locale}', 'middleware' => 'set.locale'], function () {
    Route::get('/', 'HomeController@index');
    Route::get('/pricing', 'PricingController@show');
});

// app/Http/Middleware/SetLocale.php
namespace App\Http\Middleware;

use Closure;

class SetLocale
{
    public function handle($request, Closure $next)
    {
        $locale = $request->route('locale');
        
        if (in_array($locale, config('app.supported_locales'))) {
            app()->setLocale($locale);
        }

        return $next($request);
    }
}

Now URLs like /en/pricing and /es/pricing will render in the correct language. This pattern works well for marketing sites and public content.

Translate validation messages

Validation messages are often the first strings users see when something goes wrong. Laravel provides a built-in structure for translating these messages per language and per rule.

Create a validation file for each language:

php
<?php
// lang/en/validation.php
return [
    'required' => 'The :attribute field is required.',
    'email' => 'The :attribute must be a valid email address.',
    'min' => [
        'numeric' => 'The :attribute must be at least :min.',
        'string' => 'The :attribute must be at least :min characters.',
    ],
    'unique' => 'The :attribute has already been taken.',
    'custom' => [
        'email.required' => 'We need your email address to proceed.',
        'password.min' => 'Your password must be stronger (at least 8 characters).',
    ],
];

// lang/es/validation.php
return [
    'required' => 'El campo :attribute es requerido.',
    'email' => 'El :attribute debe ser una dirección de correo válida.',
    'min' => [
        'numeric' => 'El :attribute debe ser al menos :min.',
        'string' => 'El :attribute debe tener al menos :min caracteres.',
    ],
    'unique' => 'El :attribute ya ha sido tomado.',
    'custom' => [
        'email.required' => 'Necesitamos tu dirección de correo para continuar.',
        'password.min' => 'Tu contraseña debe ser más fuerte (al menos 8 caracteres).',
    ],
];

When you validate using the Form Request class or the Validator facade, Laravel automatically resolves validation messages from the current locale:

php
<?php
// app/Http/Requests/StoreUserRequest.php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreUserRequest extends FormRequest
{
    public function rules()
    {
        return [
            'email' => 'required|email|unique:users',
            'password' => 'required|min:8|confirmed',
            'name' => 'required|string|max:255',
        ];
    }

    public function messages()
    {
        return [
            'email.required' => __('validation.custom.email.required'),
            'password.min' => __('validation.custom.password.min'),
        ];
    }
}

If you don't define custom messages, Laravel uses the default keys from your validation.php files. The :attribute placeholder is automatically replaced with the field name, which you can also customize in the attributes() method:

php
<?php
public function attributes()
{
    return [
        'email' => __('fields.email'),
        'password' => __('fields.password'),
    ];
}

// lang/en/fields.php
return [
    'email' => 'Email Address',
    'password' => 'Password',
];

// lang/es/fields.php
return [
    'email' => 'Dirección de Correo',
    'password' => 'Contraseña',
];

This approach ensures that every user sees validation errors in their preferred language, making for a seamless experience across your platform.

Common mistakes

Not setting a fallback locale: If a key doesn't exist in the current language, Laravel falls back to the fallback locale. If you don't configure one, you'll see untranslated keys appear in your UI. Always set fallback_locale in config/app.php.

Hardcoding strings instead of using translation keys: It's tempting to sprinkle translated strings directly in your code, but this makes maintenance impossible. Centralize all strings in lang/ files and use helpers consistently.

Forgetting to translate attribute names in validation messages: When you use :attribute in validation messages, Laravel shows the raw database column name unless you define custom attributes. Users see "email_verified_at" instead of "Email Verified Date". Always translate attribute names for better UX.

Not considering plural rules across languages: English plurals are simple (singular/plural), but languages like Polish, Czech, or Arabic have three or more forms. Test trans_choice() thoroughly across all supported languages—don't assume all follow English rules.

Setting locale in the wrong place: Setting the locale in a controller means it won't apply to queue jobs or scheduled tasks running in the background. Middleware is the right place for request-based locale switching, but consider setting locale within job classes or commands that need specific locales.

Key takeaways

  • Organize translations in lang/ with one folder per language and domain-based files (messages.php, validation.php, etc.).
  • Use __()) for simple strings and trans_choice() for counts and pluralization across different languages.
  • Set locale per-user via middleware, per-route via URL segments, or globally in config depending on your application architecture.
  • Translate validation messages and attribute names to ensure error feedback is localized and user-friendly.
  • Always define a fallback locale and use translation keys consistently—never hardcode strings in controllers or templates.
  • Test plural forms carefully since rules vary by language; don't assume English patterns apply everywhere.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles