ZeroPHP

I18n / Translation

I18n / Translation

A lightweight i18n layer with file-based translations, inline translation blocks, locale resolution, and per-context (web/mail/CLI) state. Translations are looked up via the __() helper or the @t() view directive, both backed by Zero\Lib\I18n\Translator.


Contents


Quick start

# 1. Create a translation file
mkdir -p resources/i18n/en/pages
cat > resources/i18n/en/pages/home.json <<'JSON'
{
  "welcome": {
    "title": "Welcome",
    "message": "Hi {{ $name }}, you have {{ $count }} new messages."
  }
}
JSON
// resources/views/pages/home.php
<h1>@t('welcome.title')</h1>
<p>@t('welcome.message', ['name' => $user->name, 'count' => 3])</p>

That's it — the view picks up resources/i18n/{locale}/pages/home.{json|yaml} automatically. Missing keys return the key string itself (e.g. welcome.title) so absent strings stay visible.


Replacement tokens

Translation values are evaluated as Blade-style templates with the $replacements array extracted into scope. That means token syntax is {{ $name }}, not :name.

{
  "greeting": "Hello {{ $name }}!",
  "balance": "{{ $name }}, you have {{ $count }} unread."
}
__('greeting', ['name' => 'Tofik']);                    // "Hello Tofik!"
__('balance', ['name' => 'Tofik', 'count' => 3]);       // "Tofik, you have 3 unread."

You can use any Blade expression — conditionals, ternaries, function calls — inside {{ }}:

{
  "items": "You have {{ $n }} item{{ $n === 1 ? '' : 's' }}"
}
__('items', ['n' => 1]);  // "You have 1 item"
__('items', ['n' => 5]);  // "You have 5 items"

There's no separate trans_choice()/pluralisation helper; do branching inside the template token.


Translation files

Translations live under resources/i18n/{locale}/. JSON, YAML, and YML are all accepted; YAML uses the yaml PHP extension when present and falls back to a minimal parser otherwise.

resources/i18n/
├── en/
│   ├── pages/
│   │   ├── home.json
│   │   └── profile.yaml
│   └── mail/
│       └── welcome.yaml
└── id/
    ├── pages/
    │   └── home.json
    └── mail/
        └── welcome.yaml

The lookup path mirrors the view path. A view at resources/views/pages/home.php automatically loads resources/i18n/{locale}/pages/home.{json|yaml} — no @i18n directive needed.

To explicitly load a different file (e.g. shared strings), use @i18n('path/to/file') in templates or Translator::useFile('path/to/file') in PHP.

Dot notation

Nested keys are addressed with dots:

# resources/i18n/en/pages/home.yaml
welcome:
  title: Welcome
  cta:
    primary: Get started
    secondary: Learn more
__('welcome.title');           // "Welcome"
__('welcome.cta.primary');     // "Get started"

__() helper

__(string $key, array $replacements = [], ?string $context = null, ?string $locale = null): string

Returns the translated string for $key. Falls back through the configured fallback_locales chain; returns the raw $key if nothing matches.

__('common.welcome');                                       // active locale
__('greeting.hello', ['name' => $user->name]);              // with replacements
__('mail.subject', [], 'mail');                             // explicit context
__('common.bye', [], null, 'fr');                           // explicit locale
__('common.bye', [], 'mail', 'fr');                         // explicit context AND locale

⚠️ Argument order differs from Translator::translate(). The helper takes (key, replacements, context, locale); the underlying Translator::translate(...) takes (key, replacements, locale, context). Use the helper in app code — its order matches Laravel.


@t() view directive

Shorthand for <?php echo __(...); ?>. Identical signature, identical semantics. Use it in any Blade-style template.

<h1>@t('welcome.title')</h1>
<p>@t('welcome.message', ['name' => $user->name])</p>

{{-- Explicit context --}}
<p>@t('mail.disclaimer', [], 'mail')</p>

{{-- Explicit locale --}}
<p>@t('common.bye', [], null, 'fr')</p>

Compiles to a single PHP echo, so you can mix and match with regular {{ }} echos:

<p>{{ __('welcome.title') }}</p>     {{-- escaped, equivalent to @t() --}}
<p>@t('welcome.title')</p>           {{-- shorter, same output --}}

@i18n(...) view directive

Two forms:

@i18n('file/path') — load a translation file

Switch the active translation file for the rest of the view (and any included children) without changing the locale.

@i18n('emails/reset-password')

<p>@t('greeting', ['name' => $user->name])</p>
<p>@t('cta.button')</p>

Loads resources/i18n/{locale}/emails/reset-password.{json|yaml}. Useful for shared strings or when several views reuse the same dictionary.

@i18n('format') ... @endi18n — inline block

Register translations directly in the view. The format argument is 'yaml' or 'json'; the block body is the catalog content. Translations only live for the duration of the render.

@i18n('yaml')
en:
  notice: If you {{ $name }} did not request this, ignore the email.
  info:
    message: Just an example.
id:
  notice: Jika {{ $name }} tidak meminta ini, abaikan email ini.
  info:
    message: Contoh saja.
@endi18n

<p>@t('notice', ['name' => $name])</p>
<p>@t('info.message')</p>

The same block accepts JSON if you prefer:

@i18n('json')
{
  "en": { "notice": "Hi {{ $name }}!" },
  "id": { "notice": "Halo {{ $name }}!" }
}
@endi18n

<p>@t('notice', ['name' => $name])</p>

Inline blocks are great for one-off views that don't justify a separate file (typical for transactional emails).


Inline translation blocks

(Same mechanism as @i18n('format') ... @endi18n above — full reference there.)

Things to know:

  • Inline blocks merge with file-based translations. File-based keys win when both define the same key (configurable via Translator::registerInline() if you need a different precedence).
  • The block body sees the same $replacements array as the surrounding view — {{ $name }} inside the YAML refers to the variable in the calling template.
  • Blocks are scoped to the current render; switching views resets them.

Locale switching

From a controller / middleware

use Zero\Lib\I18n\Translator;

// Validate against config and persist via the configured strategy
// (cookie / session — see Configuration). Returns the resolved locale.
$applied = set_locale('id');

// Don't persist — runtime only
set_locale('id', persist: false);

// Switch the mail context independently of the web context
set_locale('id', context: 'mail');

set_locale() is the safe path: it falls back to default_locale when the requested locale isn't in supported_locales, and only persists when the resolver is cookie or session.

Lower-level

Translator::setLocale('id');                       // raw — no validation, no persistence
Translator::setLocaleFromConfig('id', 'mail');     // same as set_locale()

Read the current locale

locale();                  // 'en' (active web locale)
locale('mail');            // 'en' (mail context locale)
locales();                 // ['en', 'id', 'fr'] (configured for web)
locales('mail');           // ['en', 'id', 'fr'] (configured for mail)

Contexts (web vs mail vs CLI)

Each context has its own active locale, fallback chain, and resolver strategy. Built-in contexts:

ContextSourceWhen it's active
webconfig('i18n.web')HTTP requests by default
mailconfig('i18n.email')When rendering mail templates (auto-pushed by view path mail/...)
customconfig('i18n.<name>')When you push it manually

Manual push/pop

Wrap a block of code so translations resolve under a different context:

use Zero\Lib\I18n\Translator;

Translator::pushContext('mail');
try {
    $subject = __('subject');           // resolves under the mail locale
    $body    = view('mail.welcome')->render();
} finally {
    Translator::popContext();
}

View::render() auto-pushes the right context for views under mail/... (see Translator::resolveContextForView()), so most of the time you don't push manually.

Why contexts matter

Common case: your site runs in English by default, but a logged-in user picked Indonesian. They request a password reset — the email should be in Indonesian, but the HTTP response that confirmed the reset should keep the active web locale. Two contexts keep these independent.

// In the password reset controller
set_locale($user->preferred_locale, 'mail', persist: false);
Mail::send($user, new PasswordResetMail($token));
// Web response continues in whatever the user is browsing in

Locale resolution & fallbacks

The resolver strategy is configured per-context in config/i18n.php. Strategies in order of preference:

  1. URL prefix/en/dashboard, segment configurable
  2. Domain / subdomainid.example.com → 'id' via the domain.map config
  3. Sessionsession_key in the config
  4. Cookiecookie_key in the config
  5. Header — defaults to X-Locale
  6. Custom resolver — closure that returns a locale string

The first strategy that yields a supported locale wins. If none match, default_locale is used.

Fallback chain

When a key is missing in the active locale, the translator walks the fallback_locales list:

'en-GB' → 'en' → default_locale

Configure with:

'fallback_locales' => ['en-US', 'en'],

If every fallback also misses the key, the raw key string is returned (visible to the user — pick keys carefully).


Translator API reference

use Zero\Lib\I18n\Translator;

Translator::translate(string $key, array $replacements = [], ?string $locale = null, ?string $context = null): string

The core lookup. Most app code uses __() instead.

Translator::translate('common.welcome');
Translator::translate('greeting.hello', ['name' => 'Tofik']);
Translator::translate('common.bye', [], 'fr');                 // explicit locale
Translator::translate('subject', [], null, 'mail');            // explicit context

Translator::locale(?string $context = null): string

Currently active locale for the given (or current) context.

Translator::locale();              // 'en'
Translator::locale('mail');        // 'id'

Translator::availableLocales(?string $context = null): array

The configured supported_locales (or [default_locale] if none configured).

Translator::availableLocales();        // ['en', 'id', 'fr']
Translator::availableLocales('mail');  // ['en', 'id']

Translator::setLocale(string $locale, ?string $context = null): void

Force the locale for the given context. No validation, no persistence. Use when you've already verified the locale is supported.

Translator::setLocale('id');
Translator::setLocale('id', 'mail');

Translator::setLocaleFromConfig(string $locale, ?string $context = null, bool $persist = true): string

Validate against supported_locales, fall back to default_locale if not, optionally persist via the configured resolver. Returns the locale that was actually applied.

$applied = Translator::setLocaleFromConfig('id');             // persists
$applied = Translator::setLocaleFromConfig('id', 'mail', persist: false);

The set_locale() global helper wraps this.

Translator::useFile(string $file, ?string $locale = null, ?string $context = null): void

Load a translation file into the current context without changing the locale.

Translator::useFile('emails/reset-password');
__('subject');         // pulled from resources/i18n/{locale}/emails/reset-password.{ext}

In templates, prefer @i18n('emails/reset-password').

Translator::useView(string $viewPath, ?string $context = null): void

Bind a view path's default translation file. Called automatically by View::render().

Translator::useView('pages/home');  // loads resources/i18n/{locale}/pages/home.{ext}

Translator::registerInline(string $format, string $content, ?string $context = null): void

Programmatic equivalent of @i18n('yaml') ... @endi18n. Most code uses the directive.

Translator::registerInline('json', json_encode([
    'en' => ['title' => 'Hi'],
    'id' => ['title' => 'Halo'],
]));

Translator::pushContext(string $context): void / Translator::popContext(): void

Stack a context for a scope (see Contexts).

Translator::pushContext('mail');
$subject = __('subject');
Translator::popContext();

Translator::currentContext(): string

The top of the context stack.

Translator::currentContext();  // 'web' or 'mail' or whatever's pushed

Translator::resolveContextForView(string $viewPath): string

Internal helper used by View::render() to map a view path to the right context (e.g. anything under mail/... resolves to 'mail').

Translator::resolveContextForView('mail.welcome');  // 'mail'
Translator::resolveContextForView('pages.home');    // 'web'

Configuration

config/i18n.php:

return [
    'web' => [
        'default_locale' => 'en',
        'supported_locales' => ['en', 'id', 'it', 'cn'],
        'fallback_locales' => ['en'],
        'resolver' => 'url',          // url | domain | session | cookie | header | custom
        'url' => [
            'enabled' => true,
            'segment' => 1,           // /en/path -> segment 1
        ],
        'session_key' => 'locale',
        'cookie_key' => 'locale',
        'header' => 'X-Locale',
        'domain' => [
            'map' => [
                'id.zero.local' => 'id',
                'nl.zero.local' => 'nl',
                'it.zero.local' => 'it',
                'cn.zero.local' => 'cn',
            ],
        ],
        'custom_resolver' => null,    // callable(): ?string
        'path' => 'resources/i18n',
    ],

    'email' => [
        'default_locale' => 'en',
        'supported_locales' => ['en', 'id', 'it', 'cn'],
        'fallback_locales' => ['en'],
        'resolver' => 'custom',       // mail typically uses an explicit per-recipient locale
        'custom_resolver' => null,
        'domain' => [
            'map' => [
                'id.zero.local' => 'id',
                'nl.zero.local' => 'nl',
                'it.zero.local' => 'it',
                'cn.zero.local' => 'cn',
            ],
        ],
        'path' => 'resources/i18n',
        'view_prefix' => 'mail',      // views under mail/* trigger this context
        'context' => 'mail',
        'subdomain' => [
            'offset' => 0,
            'ignore' => ['www'],
        ],
    ],
];

Resolver examples

// URL-prefixed locale
'resolver' => 'url',
'url' => ['enabled' => true, 'segment' => 1],
// /en/products → 'en'
// /id/products → 'id'

// Subdomain-based
'resolver' => 'domain',
'domain' => ['map' => ['id.example.com' => 'id', 'fr.example.com' => 'fr']],

// Cookie-based (most common for SPA-style apps)
'resolver' => 'cookie',
'cookie_key' => 'locale',
// set_locale('id') will write the cookie

// Custom callback (e.g. read from the authenticated user)
'resolver' => 'custom',
'custom_resolver' => fn () => auth()?->preferred_locale,

Notes

  • YAML parsing uses the PHP yaml extension if installed; otherwise a minimal parser is used.
  • Translation files are read on demand and cached for the duration of the request.
  • Missing keys return the original key string (e.g. welcome.title) — never throw.
  • The __() helper and @t directive are completely interchangeable; pick whichever reads cleaner in context.