Localization is a complex subject. There are many approaches to localizing applications, and unfortunately many of them break down for even slightly complex languages.

Some common language complexities include:

  • nouns changing their letters based on the context they're used in
  • words changing their position in a sentence based on the context they're used in
  • verbs changing their letters based on which noun is used in the sentence

Laravel's default localization system lets you translate language strings for each language, and it also supports parameters.

This can partially solve some of the issues above. For example, German would use :Resource anlegen instead Create :Resource — the order of words changed. And that can be solved easily:

'create.title' => ':Resource anlegen',

However, that's basically the end of Laravel's localization capabilities. If the resource changes its form due to the context, it will be wrong. If the verb (anlegen in this case) changes its form due to which noun is used, it will be wrong.

This is the case in Czech for example. Create :Resource would be Vytvořit :resource (also notice how the capitalization changed), which will work for some resources, for example:

// 'create.title' => 'Vytvořit :resource',

$resource = ProductResource::class;
$label = $resource::label(); // Produkt

__('create.title', ['resource' => lcfirst($label)]);
=> 'Vytvořit produkt' ✅

However, it breaks for many other words:

// 'create.title' => 'Vytvořit :resource',

$resource = OrderResource::class;
$label = $resource::label(); // Objednávka

__('create.title', ['resource' => lcfirst($label)]);
=> 'Vytvořit objednávka' ❌

Which is wrong. It should be Vytvořit objednávku. This is due to a grammatical thing called declension which means inflection of nouns. Nouns can change their form based on the context they're used in. And here, it breaks our app.

The current example (Vytvořit objednávka) is grammatically wrong and also feels very awkward. You don't want your admin panel to feel like it's broken and that little care went into it. If users see that you can't even make text on a button grammatically correct, they won't have much reason to trust that you can provide a secure service either. It sounds dumb, but in many admin panels it's really harder to customize text on buttons than the entire backend logic. But the user doesn't know and doesn't care. They see broken buttons (especially in this way — it's not just grammatically incorrect, it literally feels like data fed into a template with little effort), so they'll wonder what else is broken.

For this reason, perfect localization is crucial. Your app is either correctly translated, or it's broken. There's nothing in between.


So what we really want is to specify language strings for each resource individually.

And Lean lets you do just that. You can translate the global lean/resources language file, and then you can add optional overrides for specific resources.

This gives you complete control over each language string. You can change what text is displayed on any given action for any given resource.

To continue our example, here's how you'd fully localize OrderResource:

public static array $lang = [
    'index.new' => 'Nová objednávka',

    'create.title' => 'Vytvořit objednávku',
    'create.submit' => 'Vytvořit objednávku',
    'create.another' => 'Uložit a přidat další objednávku',

    'edit.title' => 'Vytvořit objednávku',
    'edit.submit' => 'Vytvořit objednávku',

    'index.pagination.results' => 'Zobrazeno :start až :end z :total objednávek',

    'notifications.created' => 'Objednávka vytvořena',
    'notifications.update' => 'Objednávka upravena',
    'notifications.deleted' => 'Objednávka smazána',

That's it. All order-related screens are now perfectly translated. 10 lines of code (it's not even code) excluding new lines.

In the code above, we even went as far as to customize the Showing x to y of z results string showed by the paginator. Now it doesn't say "results", but "orders" (in Czech). Very user-friendly and makes a great impression. Shows that a lot of care went into your admin panel and that it's not just a template. (Even though for you it's almost like using a template 😉)

The notifications are also an interesting case. The resource name is in the same inflection form as the label (Objednávka), but the verb changes its suffix due to the gender of the resource name. For example, Order is Objednávka vytvořena but Product is Produkt vytvořen. Notice how the last letter is different. This is called conjugation, the verb type of inflection.

You may also define a lang() method if you need to use runtime logic (e.g. the __() helper) to generate these strings.

This is what you should use if your app has more than one language. The localization strings should be in specific files, like resources/lang/{xx}/order.

public static function lang(): array
    return [
        'create.title' => __('order.create'),
        'create.submit' => __('order.submit'),

And of course, you can customize the singular & plural labels of a resource:

public static function label(): string
    return 'Objednávka';

public static function pluralLabel(): string
    return 'Objednávky';

The lowercase form of the singular label is what's passed to each language string as :resource.


You can customize a field's label using the label() method:


If you're using multiple languages, you should use __() calls:


The structure of your language strings is something you should decide yourself based on the complexity of your app and the languages you deal with. In the example above, we just use __('Name') but you may want to use __('fields.name') if you want a separate name string for Lean fields, or maybe even more appropriately {resource}.name, e.g. product.name. This solves the issue where name can actually be many different words based on what resource it's used in. This exact example actually applies in Czech. Name is Název, which is correct for locations, companies, etc. But people are an animate (alive) object, so Název is not correct, we should use Jméno which strictly means name of a person (or a pet, if you name your pets).


Localizing pages is simple, because pages are simple. They just have a label that appears in the menu and that's it. The rest is your code.

To set the label, define a label() method:

public static function label(): string
    return __('Settings'),


As explained above, localization comes with massive complexity. So to increase the good in the world, we've extracted all of the localization logic from Lean into a separate package called Gloss.

You can find it on GitHub: LeanAdmin/gloss.

It adds override capabilities. For example, if we wanted to override what a few language strings when their :resource parameter is order, we'd make a simple call like this:

    'resource.create' => 'Vytvořit objednávku',
    'resource.edit' => 'Upravit objednávku',
], ['resource' => 'order']);

After that, the gloss() helper, or optionally the __() helper if you enable the override, will return Vytvořit objednávku when you call __('resource.create', ['resource' => 'order']). Without affecting other language strings.

Gloss also adds smaller quality of life improvements, such as extend() which lets you make changes to a language string after it's fully built. For example, the usual case of pagination language strings:

{!! __('Showing') !!}
<span class="font-medium">{{ $paginator->firstItem() }}</span>
{!! __('to') !!}
<span class="font-medium">{{ $paginator->lastItem() }}</span>
{!! __('of') !!}
<span class="font-medium">{{ $paginator->total() }}</span>
{!! __('results') !!}

That is horrible. The word order can change, the word form can change. Basically everything except the numbers can change.

An alternative would be having a language string with a value like this:

Showing <span class="font-medium">:first</span>
to <span class="font-medium">:last</span>
of <span class="font-medium">:total</span>

(Split into multiple lines for readability)

This has the benefit of allowing usage like this:

__('pagination.results', [
    'first' => $paginator->firstItem(),
    'last' => $paginator->lastItem(),
    'total' => $paginator->total(),

However, you don't want your translators to be writing HTML and you don't want such messy language strings.

So, Gloss solves this by letting you add the formatting after the string is fully resolved in the right language.

// 'pagination' => 'Showing :start to :end of :total results',

Gloss::extend('foo.pagination', fn ($value, $replace) => $replace($value, [
    ':start' => '<span class="font-medium">:start</span>',
    ':end' => '<span class="font-medium">:end</span>',
    ':total' => '<span class="font-medium">:total</span>',

Gloss::get('foo.pagination', ['start' => 10, 'end' => 20, 'total' => 50])
// Showing <span class="font-medium">10</span> to <span class="font-medium">20</span> of <span class="font-medium">50</span> results

And of course, the simple string can be translated in any language and it will work correctly, with formatting applied at the right places.