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
- Replacement tokens
- Translation files
__()helper@t()view directive@i18n(...)view directive- Inline translation blocks
- Locale switching
- Contexts (web vs mail vs CLI)
- Locale resolution & fallbacks
- Translator API reference
- Configuration
- Notes
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.yamlThe 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): stringReturns 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 underlyingTranslator::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
$replacementsarray 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:
| Context | Source | When it's active |
|---|---|---|
web | config('i18n.web') | HTTP requests by default |
mail | config('i18n.email') | When rendering mail templates (auto-pushed by view path mail/...) |
| custom | config('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 inLocale resolution & fallbacks
The resolver strategy is configured per-context in config/i18n.php. Strategies in order of preference:
- URL prefix —
/en/dashboard, segment configurable - Domain / subdomain —
id.example.com → 'id'via thedomain.mapconfig - Session —
session_keyin the config - Cookie —
cookie_keyin the config - Header — defaults to
X-Locale - 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_localeConfigure 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 contextTranslator::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 pushedTranslator::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
yamlextension 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@tdirective are completely interchangeable; pick whichever reads cleaner in context.