Overriding Text and Templates
Change a shipped wording or piece of markup, in a way the next upgrade will not undo.
Overview
Every visible string comes out of a language file and every screen out of a template. Editing those works until the next version overwrites them.
Four seams: a translation filter for one string, the theme content layer for website copy. Then a variables filter for what a template gets, injection points for what it prints.
Prerequisites
- The exact key; the same word appears in several packages.
- Somewhere for a listener: the hooks directory or your module.
- The installed language list, which is a directory listing.
Structure
Four stores hold text, each with its own accessor. The wrong one fails silently.
| Store | Path | Read with |
|---|---|---|
| Root packages: needs, date, errors, actions, constants, blocks, routes | coremio/locale/{lang}/{name}.php | Language::g() |
| Controller packages, one per surface | coremio/locale/{lang}/cm/{admin|website|system}/{name}.php | Language::gc() |
| Module strings | coremio/modules/{Type}/{Name}/lang/{lang}.php | $this->lang['key'] |
| Theme strings and page copy | templates/website/{Theme}/locale/{lang}.php | Language::gc("theme/key") or the theme's accessor |
Panel edits go to content/{lang}/{scope}.php and win. An upgrade replaces locale/, never content/.
Walkthrough
Find the Key
- Decide the store from the screen.
- Search the language directory for the visible text.
- Confirm on the command line.
- Note the language: one says nothing about the others.
Override an Existing String
- For a controller string, listen on the translation filter and rewrite the value in place.
- Keep the listener cheap: it runs on every controller string.
- For website copy, use the theme's content layer.
- The filter does not reach root package strings. Change those at the point of use.
Add a New String
- Pick the surface's package and match the neighbours' shape.
- Add it to every installed language; a missing key shows nothing.
- Nest the array when the key contains a slash.
- Never hardcode a visible string.
Override a Template
- Decide: what the template receives, or what it prints.
- To change the data, filter the variables.
- To change the markup, use the nearest injection point.
- To replace a public page, copy the view into your theme.
Reference
The Accessors
// Root packages. $key is "{file}/{key}/{subkey}", one slash per array level.
// Returns false when any segment is missing. There is NO language fallback.
public static function g($key = '', $replaces = [], $slang = ''): array|string|int|bool;
// Controller packages under cm/. Same shape, one extra leading segment for the area.
public static function gc($name = '', $replaces = [], $slang = ''): array|string|int|bool;
// The active language code, for instance "en".
public static function selected(): string;
// Machine value to human label. $dictionary is the locale prefix the value is
// appended to ("admin/orders/status-"), '@toggle', or '@list:{prefix}'.
// An unknown value falls back to itself rather than to an empty string.
public static function enum_label($value = '', $dictionary = '', $slang = ''): string;
// Writes one package file. See the warning below: this REPLACES, it does not merge.
public static function save($key = '', $data = [], $lk = ''): int|bool;
false.
What a Key May Look Like
<?php
return [
// Plain. Read as Language::gc('admin/services/page-list').
'page-list' => 'Services',
// With placeholders. Two notations exist and both are literal search targets:
// the replacement map's keys are matched as written, braces and colons included.
'welcome' => 'Hello {name}, you have {count} messages.',
'meta-title' => 'Service detail - :name',
// Declared form. g()/gc() return the 'content' string; 'variables' is metadata
// for the translation tooling and never reaches the screen.
'quota-warning' => [
'content' => 'Only {left} of {total} remaining.',
'variables' => '{left},{total}',
],
// A slash in a key is DEPTH, not part of the name. This is the only way to
// make Language::gc('admin/services/labels/renew') resolve.
'labels' => [
'renew' => 'Renew',
'suspend' => 'Suspend',
],
];
false, with no language fallback and nothing in the log.
{SITE-URL} becomes the base address.
The Four Seams
content override matching keys in locale. Written from the panel.
How a Theme Resolves a String
// One string, with optional placeholder substitution.
public function lang(string $key, array $vars = []): string;
// The content editor's own surface, for a module that wants to read or write it.
public static function contentScopes(string $themeName): array;
public static function scopeDefaults(string $themeName, string $lang, string $scope): array;
public static function scopeOverrides(string $themeName, string $lang, string $scope): array;
public static function writeScopeOverrides(string $themeName, string $lang, string $scope, array $values): bool;
| Order | Source | Note |
|---|---|---|
| 1 | The operator's override | Wins over all below |
| 2 | The theme's own text | locale/{lang}.php plus locale/{lang}/{scope}.php |
| 3 | The same key in English | The only language fallback |
| 4 | The core packages, controller first then root | Lets a theme reuse a platform string |
| 5 | The key itself | Never blank, unlike the core accessors |
A scope is common or a view path, and only the active one is loaded. A view under page drops that prefix.
Writing a Package File
The array you hand it becomes the whole file. Paired with an accessor that answers with an empty array, a read-modify-write cycle can reduce a package to one key.
Example
Renaming a term without a language file.
// Key => replacement, per language. A map lookup keeps the listener O(1); a
// str_replace over every string in the panel would not be acceptable here.
const ACME_WORDING = [
'en' => [
'admin/services/page-list' => 'Subscriptions',
'admin/services/page-title' => 'Subscription detail',
],
'tr' => [
'admin/services/page-list' => 'Abonelikler',
'admin/services/page-title' => 'Abonelik detayı',
],
];
Hook::add('filter:i18n.translation', 10, function (&$value, $key, $lang) {
// Only controller-package strings arrive here, and only when they resolved
// to a string: a missing key never fires this hook, so a gap cannot be
// filled from here.
$value = ACME_WORDING[$lang][$key] ?? $value;
});
Changing what a template receives; the path test narrows it.
Hook::add('filter:template.variables', 10, function ($template_path, $data) {
// Normalise: the path arrives with the platform's directory separator.
$path = str_replace('\\', '/', (string) $template_path);
if (!str_ends_with($path, 'admin/services/detail.php')) return null; // not ours
// The return REPLACES $data entirely. Building on the incoming array is not
// a style choice: dropping template_dir or ui_lang breaks the render.
$data['acme_banner'] = Language::gc('admin/services/acme-banner');
return $data;
});
// Markup, at a position the platform announces. The return value is printed
// as-is, so anything variable in it has to be escaped here.
Hook::add('ui:admin.head.css', 10, fn (): string
=> '<link rel="stylesheet" href="' . Utility::AppAdress() . '/resources/acme/panel.css">');
Adding your own string, in every language.
// The installed languages are a directory listing, never a hardcoded pair.
$langs = array_map('basename', glob(ROOT_DIR . 'coremio' . DS . 'locale' . DS . '*', GLOB_ONLYDIR) ?: []);
foreach ($langs as $lang) {
// Resolve in that language explicitly: the third argument, not the second.
$existing = Language::gc('admin/services/acme-banner', [], $lang);
// false means the key is missing THERE, even if it resolves in English.
if ($existing === false)
echo 'missing in ' . $lang . PHP_EOL;
}
// Reading one key in one language, from the command line, is how you settle an
// argument about which accessor a package needs.
var_dump(Language::g('needs/untitled', [], 'tr')); // string, root package
var_dump(Language::gc('needs/untitled', [], 'tr')); // false, wrong accessor
The theme side: an override is a file, not a listener.
<?php
// Overrides the matching keys of locale/en/home.php for this theme only.
// The panel writes this file; a module may write it through the theme API.
// An upgrade replaces locale/, never content/, so the override survives.
return [
'hero-title' => 'Hosting that stays out of your way',
'hero-subtitle' => 'Deploy in a minute, scale when you need to.',
];
$theme = Theme::active()->getName();
// Read the shipped defaults and the operator's current overrides separately:
// they are two layers, and merging them before writing would freeze the defaults.
$defaults = Theme::scopeDefaults($theme, 'en', 'home');
$overrides = Theme::scopeOverrides($theme, 'en', 'home');
$overrides['hero-title'] = 'Hosting that stays out of your way';
// An empty array removes the override file entirely, restoring the defaults.
Theme::writeScopeOverrides($theme, 'en', 'home', $overrides);
Pitfalls
There is no language fallback and no warning. A missing segment returns false or an empty array.
Read the installed languages from the directory. A key added to some shows nothing in the rest.
Returning only your own additions wipes out everything the template needed. Add to the incoming array and return all of it.
Every resolved controller string passes through it, so the cost is paid once per string. Keep it to a map lookup.
Each segment is another array level, so a flat key with a slash is never found.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.