Search by

goodcat / laravel-l10n

matteopalazzo

An opinionated Laravel package for app localization

Package info

github.com/goodcat-dev/laravel-l10n

pkg:composer/goodcat/laravel-l10n

Statistics

Installs: 279

Dependents: 0

Suggesters: 0

Stars: 17

Open Issues: 0

v0.5.4 2026-10-24 20:16 UTC

This package is auto-updated.

Last update: 2026-10-24 21:50:13 UTC


README

Latest Version on Packagist GitHub Tests Action Status

An opinionated Laravel package for app localization.

Table of Contents

Quickstart

Get started with laravel-l10n in two steps.

  1. Download the package via Composer.
    composer require goodcat/laravel-l10n
  2. Define localized routes using the lang() method.
    Route::get('/example', Controller::class)
        ->lang(['fr', 'de', 'it', 'es']);

That's it. You're all set to start using laravel-l10n.

Localized Routes

Defining Localized Routes

Use the lang() method to define which locales a route should support:

Route::get('/example', Controller::class)
    ->lang(['es', 'fr', 'it']);

This will generate:

  • /example (Canonical, fallback locale)
  • /es/ejemplo (Spanish, translated via language file)
  • /fr/example (French, no translation defined)
  • /it/example (Italian, no translation defined)

The canonical route serves Laravel's fallback locale (APP_FALLBACK_LOCALE in .env). It must match the language your routes and content are written in. Listing it in lang() is harmless: no extra route is registered.

Each localized route sets the application's locale to its language. Routes without lang() leave it unchanged (APP_LOCALE by default).

Route groups

To avoid repetitive language definitions on every single route, you can use Route::lang()->group():

Route::lang(['es', 'it'])->group(function () {
    Route::get('/example', fn () => 'Hello, World!');
    Route::get('/another', fn () => 'Another route');
});

All routes inside the group inherit its locales. Use lang() on an individual route to add more languages:

Route::lang(['es', 'it'])->group(function () {
    Route::get('/example', fn () => 'Hello, World!'); // es, it
    Route::get('/another', fn () => 'Another route')
        ->lang(['fr']); // es, it, fr
});

Route Strategy

The route_strategy option in config/l10n.php controls how locale prefixes are applied:

  • prefix_except_default (default) keeps the fallback locale unprefixed (e.g. /example, /es/ejemplo).
  • prefix prefixes every locale and does not register an unprefixed route (e.g. /en/example, /es/ejemplo).
  • no_prefix uses translated URIs without locale prefixes (e.g. /example, /ejemplo).

Warning

With the no_prefix strategy, a translation that shares its domain and URI with the canonical route or an earlier translation is skipped and falls back to the canonical route.

Note

config/l10n.php is created by publishing the package config: php artisan vendor:publish --tag=l10n-config.

Translating Route URIs

Manage route translations in dedicated language files. The expected file structure is as follows:

/lang
├── /es
│   └── routes.php
├── /fr
│   └── routes.php
└── /it
    └── routes.php

Inside your routes.php file, map the original route URI to a translated slug:

// lang/es/routes.php
return [
    'example' => 'ejemplo',
];

If no translation is provided for a given locale, the original URI is used as-is.

Note

The key should be the route URI without the leading slash. For example, for Route::get('/example'), the key should be example.

For routes with a custom binding key, omit :slug from the translation key:

// routes/web.php
Route::get('/article/{post:slug}', [PostController::class, 'show'])
    ->lang(['it']);
// lang/it/routes.php
return [
    'article/{post}' => 'articolo/{post}',
];

The localized route still binds post by its slug.

Translated Route Keys

If the route key itself is translated (for example a slug stored per locale), implement HasLocalizedRouteKey on the model. The package calls it when generating a URL for a localized route, passing the route locale and the binding field. This example uses spatie/laravel-translatable:

use Goodcat\L10n\Contracts\HasLocalizedRouteKey;

class Post extends Model implements HasLocalizedRouteKey
{
    use HasTranslations;

    public array $translatable = ['slug'];

    public function getLocalizedRouteKey(string $locale, ?string $field = null): ?string
    {
        return $this->getTranslation($field ?? $this->getRouteKeyName(), $locale, false) ?: null;
    }
}
route('posts.show', ['post' => $post, 'lang' => 'it']); // Returns "/it/articolo/ciao-mondo"

Return null when the model has no translation for a locale. The locale switcher, the hreflang links and the RedirectToPreferredLocale middleware skip that locale. See Missing Localized Route Keys for route() and action().

Incoming requests are resolved by the model's own binding. Look up the slug in the route locale to answer with a 404 when the post has no translation, or its slug belongs to another locale:

public function resolveRouteBinding($value, $field = null): ?self
{
    $field ??= $this->getRouteKeyName();

    return $this->where("$field->".app()->getLocale(), $value)->first();
}

To redirect instead, let the binding match the slug of any locale and send the visitor to the right URL:

public function show(Request $request, Post $post)
{
    if ($request->route()->originalParameter('post') !== $post->getLocalizedRouteKey(app()->getLocale())) {
        return redirect()->route('posts.show', $post, 301);
    }

    // ...
}

Domain Translations

If your application uses domain-based routing, you can translate domains in the same routes.php language files. The key is the original domain string:

// lang/es/routes.php
return [
    'example' => 'ejemplo',
    'example.com' => 'es.example.com',
];

Note

Localized routes support Laravel's route caching (php artisan route:cache).

URL Generation

Using the route() and action() Helpers

Use Laravel's route() helper as usual to generate URLs in the current language. Pass lang to link to another language:

// Assuming the current locale is 'fr'
route('example'); // Returns "/fr/example"

// To generate a URL for a different locale
route('example', ['lang' => 'en']); // Returns "/example"

// If a translation exists for 'es' in lang/es/routes.php, the translated slug is used
route('example', ['lang' => 'es']); // Returns "/es/ejemplo"

Translations of a named route are named with a locale suffix:

route('example.es'); // Returns "/es/ejemplo"

The action() helper works the same way:

action(Controller::class, ['lang' => 'es']); // Returns "/es/ejemplo"

Warning

lang is reserved for selecting the URL's language. Use a different name for route parameters: URLs containing {lang} cannot be generated with route() or action().

Missing Localized Route Keys

When a model has no localized route key for a locale, route() and action() fall back to the canonical route, unless the locale is requested explicitly:

// The post has no Italian slug and the application locale is "it"
route('posts.show', $post); // Returns "/article/hello-world"

route('posts.show', ['post' => $post, 'lang' => 'it']); // Throws MissingLocalizedRouteKeyException
route('posts.show.it', $post); // Throws MissingLocalizedRouteKeyException

MissingLocalizedRouteKeyException extends UrlGenerationException. Catch it to skip the locale, for example in a sitemap:

use Goodcat\L10n\Routing\Exceptions\MissingLocalizedRouteKeyException;

try {
    $url = route('posts.show', ['post' => $post, 'lang' => 'it']);
} catch (MissingLocalizedRouteKeyException) {
    // The post has no Italian slug
}

The canonical route key is always required: without it, a MissingLocalizedRouteKeyException is thrown.

Locale Preference

To detect the user's preferred language, add SetPreferredLocale to your existing bootstrap/app.php configuration:

->withMiddleware(function (Middleware $middleware): void {
    $middleware->web(append: [
        \Goodcat\L10n\Middleware\SetPreferredLocale::class,
    ]);
})

This optional middleware resolves a preference without changing the active locale.

By default, the package checks the following sources in order:

  1. SessionLocale: Checks if a locale was set in the session. (deprecated)
  2. UserLocale: Checks if the authenticated user has a preferred locale (the user model must implement Laravel's Illuminate\Contracts\Translation\HasLocalePreference interface).
  3. BrowserLocale: Falls back to the browser's Accept-Language header, keeping all its languages in order of preference.

Redirecting to the Preferred Locale

Optionally add RedirectToPreferredLocale after SetPreferredLocale in the web middleware group:

$middleware->web(append: [
    \Goodcat\L10n\Middleware\SetPreferredLocale::class,
    \Goodcat\L10n\Middleware\RedirectToPreferredLocale::class,
]);

On the first GET request to a route with translations, the user is redirected to their preferred language if it is available and differs from the current one (HTTP 302). The check runs once per session, even when no redirect is needed, so the user can switch languages freely afterward.

This feature requires a session, which Laravel's web group provides. When using translated domains, configure a shared session across those domains.

Customizing Resolvers

To choose which sources to check and in what order, set the resolvers in the boot() method of your AppServiceProvider:

use Goodcat\L10n\L10n;
use Goodcat\L10n\Resolvers\BrowserLocale;

public function boot(): void
{
    L10n::$preferredLocaleResolvers = [
        new BrowserLocale,
    ];
}

Creating a Custom Resolver

Implement the LocaleResolver interface to create your own resolver:

namespace App\Resolvers;

use Goodcat\L10n\Resolvers\LocaleResolver;
use Illuminate\Http\Request;

class CookieLocale implements LocaleResolver
{
    public function resolve(Request $request): ?string
    {
        return $request->cookie('locale');
    }
}

Then add it to the resolvers in your AppServiceProvider::boot() method. Your application is responsible for setting the cookie:

use App\Resolvers\CookieLocale;
use Goodcat\L10n\L10n;
use Goodcat\L10n\Resolvers\BrowserLocale;
use Goodcat\L10n\Resolvers\UserLocale;

public function boot(): void
{
    L10n::$preferredLocaleResolvers = [
        new CookieLocale,
        new UserLocale,
        new BrowserLocale,
    ];
}

Helpers

This package adds several helper methods to your Laravel application.

Application Helpers

// Get the user's preferred locales, in order of preference
app()->getPreferredLocales(); // Returns ?array, e.g. ['es_ES', 'es', 'en']

// Get the user's first preferred locale
app()->getPreferredLocale(); // Returns ?string, e.g. 'es_ES'

// Get the best match among the given locales, or null
app()->getPreferredLocale(['en', 'es']); // Returns ?string, e.g. 'es'

// Set the user's preferred locales (dispatches PreferredLocaleUpdated event)
app()->setPreferredLocale('es');
app()->setPreferredLocale(['es_ES', 'es', 'en']);

// Check if a locale is the fallback locale
app()->isFallbackLocale('en'); // Returns bool

When given a list of locales, getPreferredLocale() follows the preferences in order and, for each one, looks for an exact match, then its language (es_ES matches es), then the first variant of the same language in the given list (es_ES matches es_MX). It returns null when nothing matches, so you can provide a default explicitly:

app()->getPreferredLocale(['en', 'es']) ?? 'en';

Route Helpers

// Get the route's language, or the fallback locale for a non-localized route
$request->route()->locale(); // Returns string

Route Matching

Use L10n::is() to check if the current route matches a given pattern, regardless of the locale:

L10n::is('dashboard');  // Matches /dashboard, /es/dashboard, /it/bacheca, etc.
L10n::is('admin.*');    // Wildcard patterns are supported, just like Route::is()

Use the original route name in your patterns, without a locale suffix.

Components

The package provides Blade components for common localization needs.

Alternate Hreflang Links

Add the alternate component to the <head> of your layout to generate <link rel="alternate" hreflang="..."> tags following Google's guidelines for localized versions:

<head>
    <x-l10n::alternate />
</head>

For a route with es and it translations, this will render:

<link rel="alternate" hreflang="en" href="https://example.com/products/42" />
<link rel="alternate" hreflang="es" href="https://example.com/es/productos/42" />
<link rel="alternate" hreflang="it" href="https://example.com/it/products/42" />
<link rel="alternate" hreflang="x-default" href="https://example.com/products/42" />

The component includes all localized variants, the fallback locale, and an x-default entry pointing to the canonical route. It renders nothing for routes without translations, and skips locales without a localized route key.

Locale Switcher

The switcher component renders a <select> element for switching between available locales. When the user selects a different locale, the page reloads to the corresponding localized URL.

<x-l10n::switcher />

For a route with es and it translations, this will render:

<select onchange="window.location = this.value">
    <option value="https://example.com/products/42" selected>en</option>
    <option value="https://example.com/es/productos/42">es</option>
    <option value="https://example.com/it/products/42">it</option>
</select>

The current locale is automatically selected. The component renders nothing for routes without translations, and skips locales without a localized route key.

You can pass any HTML attribute to the component:

<x-l10n::switcher class="locale-select" id="locale" />

Customizing the Templates

To customize the HTML output of the Blade components, publish the views:

php artisan vendor:publish --tag=l10n-views

Edit the published templates in resources/views/vendor/l10n/components/. Each template lists the variables available for customization.

Localized Views

Organize language-specific views in folders named after their locale:

/resources/views
├── example.blade.php
├── /it
│   └── example.blade.php
└── /es
    └── example.blade.php

Call view('example') as usual. When the current locale is it, it renders resources/views/it/example.blade.php if available, otherwise resources/views/example.blade.php.

JavaScript URL Generation

This package provides helper functions for generating localized URLs in your JavaScript/TypeScript frontend, for Wayfinder or Ziggy. Given the following route definition:

Route::get('/foo/{id}', Controller::class)
    ->lang(['it', 'es'])
    ->name('foo');

Both helpers resolve the locale in the following order:

  1. The lang parameter, if provided.
  2. The lang attribute of the <html lang="en"> element, normalized to Laravel's locale format (pt-BR matches a pt_BR route).
  3. The canonical route, when no localized route matches.

During server-side rendering, pass lang explicitly to select a localized route; without it, the helpers fall back to the canonical route.

Wayfinder

If you're using Wayfinder, publish the TypeScript helper:

php artisan vendor:publish --tag=l10n-wayfinder

This creates a resources/js/l10n.ts file exporting a route(routes, params?, options?) helper. Import it and pass Wayfinder's generated route functions:

import { route } from '@/l10n';
import foo from '@/routes/foo';

const esUrl = route(foo, { id: 1, lang: 'es' }).url;

Note

The route() helper only works with named routes, actions are not supported.

The LocalizedRoutes type is also exported, to type your own wrappers and composables.

To call the canonical route directly, use foo.__canonical({ id: 1 }).

Ziggy

If you're using Ziggy, publish the JavaScript helper:

php artisan vendor:publish --tag=l10n-ziggy

This creates a resources/js/l10n.js file with a route function.

Use route() as a drop-in replacement for Ziggy's route() function:

import { route } from '@/l10n';

route('foo', { id: 1, lang: 'it' });

All Ziggy arguments are forwarded, including an explicit configuration object as the fourth argument. Calling route() without a route name returns Ziggy's Router instance as usual.