Appearance
Language / i18n Management (Admin)
Flow ID: AD-26 Module(s): language Complexity: Medium Last Updated: 2026-05-26
Business Context
Ecommercen uses a file-based internationalization system for the platform's base strings (shipped lang files) and a database-backed store for admin-edited overrides on top of them. The platform ships with base language bundles containing hundreds of translation keys (the theme bundle alone has ~930 keys per language). Shop administrators can override any non-protected translation string through a centralized translation editor in the backoffice, without modifying source code. Overrides are persisted as rows in the language_overrides table and merged at runtime, with the ability to revert individual keys back to the base value at any time.
This is distinct from the MUI (Multi-Language Interface) database pattern used for entity content (product names, category descriptions, etc.). The language/i18n system here governs UI chrome: labels, button text, error messages, checkout copy, and other fixed interface strings.
Architecture
Two-Layer Language Loading
The i18n system uses a base + user override two-layer model:
- Base dataset -- immutable language files shipped with the platform. Loaded in order:
ecommercen/language/{idiom}/adv_theme_lang.php-- platform-level theme strings (~930 keys)application/language/{idiom}/{clientViews}_theme_lang.php-- client template overrides
- User dataset -- admin-editable overrides:
application/language/{idiom}/_user_theme_lang.php-- proxy file that delegates toLanguageOverrideStore::loadProxy(__FILE__)- Actual data stored in the
language_overridesDB table -- see "User Override Storage" below
At runtime, Adv_base_controller::loadFrontLangFiles() loads all three layers sequentially. Because MY_Lang::load() uses array_merge, later files override earlier keys. The _user_theme proxy resolves LanguageOverrideStore through DI and returns language-specific overrides for the (bundle, idiom) pair derived from the proxy file path.
Language File Structure
ecommercen/language/
{idiom}/
adv_theme_lang.php -- storefront UI strings (base)
adv_advisable_lang.php -- admin panel strings (~3600 keys)
adv_admin_menu_lang.php -- admin menu labels (~348 keys)
adv_scribe_lang.php -- rich text editor strings (~205 keys)
adv_external_lang.php -- external integration strings (~195 keys)
jbstrings_lang.php -- job/background task strings
application/language/
{idiom}/
{clientViews}_theme_lang.php -- per-client template overrides
_user_theme_lang.php -- proxy to DB-backed LanguageOverrideStoreSupported idioms: chinese, english, french, german, greek, italian, russian, spanish.
User Override Storage
Override persistence is owned by Advisable\Language\Storage\LanguageOverrideStore (src/Language/Storage/LanguageOverrideStore.php). It is a single concrete class — no interface, no decorator, no pluggable adapter selection — wired into the DI container at application/config/container/language.php and resolved via di()->get(LanguageOverrideStore::class) from AdvLanguageStorageModel and the 8 idiom proxy files.
API
php
class LanguageOverrideStore
{
public function getOverrides(string $bundle, string $lang): array;
public function applyChanges(string $bundle, string $lang, array $setKV, array $unsetKeys): void;
public static function loadProxy(string $proxyFile): array;
}getOverrides()-- returns thekey => valuemap for one(bundle, lang)slice. Per-request memoised in a private array ("{bundle}:{lang}"keys), so repeat reads of the same slice within one request hit DB exactly once.applyChanges()-- atomic upsert + delete in a single DB transaction.INSERT … ON DUPLICATE KEY UPDATEfor set,DELETE … WHERE lang_key IN (…)for unset. Empty input is a no-op. On success the memo for the affected slice is invalidated.loadProxy()-- static helper called by each_user_{bundle}_lang.phpproxy. Parses(bundle, lang)from the file path, resolves the store via DI, returns the override slice. On any failure returns[]so the request still serves with base language strings, and logs vialog_message('warning', …)when available (error_logfallback for very-early bootstrap contexts).
Why no cache layer?
Earlier drafts of this feature included a Redis-backed CachedStore decorator over the DB adapter. That was removed before merge: the language_overrides table is small (typically <1k rows), the slice queries are covered by the (bundle, lang) index, and a per-request memo absorbs the cost of repeat reads inside a request. Eliminating the cache layer also removed an entire class of bugs (write-through races, multi-pod cache-poisoning, TTL=0 permanent stale entries) and made the cross-pod story trivial — every read is the latest committed DB state.
Database schema -- language_overrides
Created by database/migrations/20260526120000_create_language_overrides.php:
sql
CREATE TABLE `language_overrides` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`bundle` VARCHAR(64) NOT NULL,
`lang` VARCHAR(32) NOT NULL,
`lang_key` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
`lang_value` MEDIUMTEXT NOT NULL,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uq_bundle_lang_key` (`bundle`, `lang`, `lang_key`),
KEY `idx_bundle_lang` (`bundle`, `lang`)
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;Key column choices:
lang_key utf8mb4_bin-- byte-exact uniqueness on the unique constraint. The table-defaultutf8mb4_unicode_ciwould otherwise treatProfile.Namevsprofile.name(orcafe.xvscafé.x) as the same key on insert and silently collapse them underINSERT IGNORE/ON DUPLICATE KEY UPDATE.VARCHAR(255)-- project standard for indexed utf8mb4 columns. 255 × 4 = 1020 bytes per indexed entry. Combined with(bundle, lang)prefix it sits well under InnoDB's 3072-byte DYNAMIC-row-format index limit. No my.cnf changes required on MariaDB 10.2.2+ / MySQL 5.7.7+. See20260210120630_convert_db_mb_3_to_mb_4.phpfor the project-wide utf8mb4 readiness analysis.ROW_FORMAT=DYNAMIC-- required for the 3072-byte index limit (default on MariaDB 10.2.2+, but stated explicitly to be robust against older targets).lang_value MEDIUMTEXT-- 16MB ceiling, room for long marketing copy.
Proxy files
All 8 application/language/{idiom}/_user_theme_lang.php proxy files are byte-identical:
php
<?php
/*
* WARNING: DO NOT MODIFY LANGUAGE MESSAGES IN THIS FILE
*
* Delegates to the LanguageOverrideStore via DI. Returns [] on any failure
* so the request still serves with base language strings.
*/
$lang = \Advisable\Language\Storage\LanguageOverrideStore::loadProxy(__FILE__);Rollout for existing clients
A single deploy is enough. Both migrations run in a single php migrator.php migrate call as two sequential Phinx units (each gets its own version record):
- Deploy code.
php migrator.php migrate-- createslanguage_overridesand runsMigrateUserLangsJsonToDb, seeding the table from any existingstorage/user_langs/_user_langs.json. On success the legacy file is renamed to_user_langs.json.importedso a subsequentrollback+migratecycle does not silently re-import overrides the admin has since reverted via the editor.- Writes go to DB from this point on. No env var to flip, no second deploy. Single-VM and multi-pod deployments follow the same path.
The patcher is idempotent against the (bundle, lang, lang_key) unique constraint (INSERT IGNORE) — re-running on a host where the rename failed is safe. It also bails with a hard error on any unexpected DB failure (so Phinx does NOT mark the migration as applied while the import is partial), skips non-scalar values with a warning, skips keys longer than 255 chars with a warning, and logs every early-return branch (no legacy file, empty file, non-array decode) so an operator can distinguish "no-op by design" from "no-op by misconfiguration".
URL-Based Language Switching
MY_Lang (extending MX_Lang from HMVC) handles language detection from URL segments:
- URI prefix: First segment checked against
lang_uri_abbrmap (e.g.,el->greek,en->english) - Default language ignore: When
lang_ignoreistrue, the default language abbreviation (lang_ignore_abbr) is omitted from URLs. So Greek-only shops have clean URLs without/el/prefix. - Redirect on missing: If a non-default language request lacks a valid prefix, redirects to the default language URL.
- Config rewrite: On detection,
MY_Langrewriteslanguageandlanguage_abbrconfig items and adjustsindex_pagefor URL generation.
Configuration in application/config/languages.php:
php
$config['language_abbr'] = 'el'; // default abbreviation
$config['lang_uri_abbr'] = ['el' => 'greek']; // abbreviation -> idiom map
$config['lang_ignore'] = true; // hide default lang from URL
$config['lang_desc'] = ['el' => 'ΕΛ']; // storefront language labels
$config['admin_languages'] = ['el' => 'ΕΛ']; // admin-available languagesThe t() global helper function (defined in MY_Lang.php) translates keys with vsprintf substitution:
php
function t(string $line, array $data = []): stringAdminLangLoader
A lightweight MY_Lang subclass (ecommercen/language/AdminLangLoader.php) that skips the parent constructor (no URI detection). Used by AdvLanguageStorageModel::fetchLanguages() to load base and user language files in isolation for the admin editor, without side effects on the running request's language state.
Admin Translation Editor
Controllers
| Controller | File | Lines | Purpose |
|---|---|---|---|
AdvLanguageAdmin | ecommercen/language/controllers/AdvLanguageAdmin.php | 26 | Renders the Vue listing page |
AdvApiLanguageAdmin | ecommercen/language/controllers/api/AdvApiLanguageAdmin.php | 168 | REST API: list, edit, revert |
Language_admin | application/modules/language/controllers/Language_admin.php | 6 | Application-level thin wrapper |
Api_language_admin | application/modules/language/controllers/api/Api_language_admin.php | 8 | Application-level thin wrapper |
Model
AdvLanguageStorageModel (ecommercen/language/models/AdvLanguageStorageModel.php, 412 lines) -- the core of the translation management system. Extends Adv_base_model.
Key methods:
| Method | Purpose |
|---|---|
lookupLanguages() | Loads base + user datasets for a language/bundle, applies labeling, visibility, protection |
search() | Fuzzy + exact (quoted) search across translation values |
setMessages() | Validates writability, computes the set/unset diff via array_diff_assoc / array_intersect_assoc, delegates to LanguageOverrideStore::applyChanges() in one atomic call |
unsetMessages() | Validates writability, delegates to LanguageOverrideStore::applyChanges() with empty setKV |
filterWritable() | Returns only keys the current user role is allowed to edit |
getAllLanguageBundles() | Returns bundle keys from editable_languages config |
getAllFilters() | Returns available filter definitions (currently: show_only_modified) |
applyFilters() | Applies filter queries to the dataset |
API Endpoints
| Method | Route | HTTP | Description |
|---|---|---|---|
list() | language_admin/api/list | GET | Paginated listing with search, bundle/language selection, filters |
edit() | language_admin/api/edit/{lang}/{bundle} | PUT | Save translation overrides (key-value pairs) |
revert() | language_admin/api/revert/{lang}/{bundle}/{keys...} | DELETE | Revert specific keys to base values |
Routes defined in application/config/routes.php (lines 773-780) with optional {lang}/ prefix.
API Response Structure (list)
json
{
"success": true,
"data": {
"availableLanguages": { "el": "ΕΛ", "en": "EN" },
"availableBundles": ["theme"],
"pagination": { "total": 930, "totalPages": 93, "page": 1, "pageSize": 10 },
"protected": ["some.system.key", ...],
"tagged": { "checkout.back": ["checkout"], ... },
"usedTags": { "checkout": { "label": "...", "style": {...} } },
"messages": {
"base": { "checkout.back": "Back", ... },
"user": { "checkout.back": "Go Back", ... }
}
}
}Frontend (Vue 2)
Entry: assets/admin/js/languages.js -- mounts LanguagesListing Vue component into #languages-page.
Component: assets/admin/js/languages/components/LanguagesListing.vue (466 lines) -- full-featured SPA editor.
Features:
- Language selector: Dropdown populated from
availableLanguages(from API) - Bundle selector: Dropdown for language bundles (e.g.,
theme) - Search: Free-text input triggering API re-fetch with fuzzy/exact matching
- Pagination: Configurable page sizes (10, 20, 50, 100) with first/prev/next/last navigation
- Show only modified: Checkbox filter for keys with user overrides
- Inline editing: Click Edit to expand a textarea for the translation value
- Save: PUT request with
{ messageKV: { key: value } }body - Revert: DELETE request to remove user override and restore base value
- Tag display: Color-coded tag pills on each translation entry
- Protected keys: Non-editable keys shown without action buttons
Built with: assets/admin/js/languages.js -> public/ui/admin/dist/languages.js via Laravel Mix (webpack.mix.admin.js).
Rules and Protection System
The editable_languages config in application/config/languages.php defines a rule-based access control system for translation keys.
Rule Structure
Each rule specifies:
matches: Array of exact key strings or regex patterns (prefixed with/)bundles: Which language bundles the rule applies to (defaults to all)editableBy: Array ofAUTH_ROLE_*constants that can modify matching keysvisibleBy: Array ofAUTH_ROLE_*constants that can see matching keystags: Array of tag keys for visual categorization
Rule Evaluation
Rules are evaluated in order against each translation key. The first matching rule wins. A catch-all rule (/.*/) must always be the last entry.
php
// Example rules (simplified)
[
['matches' => ['global.bank_accounts.alpha.account'], 'editableBy' => [ADVISABLE], 'visibleBy' => [ADVISABLE], 'tags' => ['bank_account_info']],
['matches' => ['/cart\.(.+)/'], 'editableBy' => [ADVISABLE], 'tags' => ['cart']],
['matches' => ['/customer\.(.+)/'], 'editableBy' => [ADVISABLE, ADMIN], 'visibleBy' => [ADVISABLE, ADMIN], 'tags' => ['customer']],
['matches' => ['/checkout\.(.+)/'], 'editableBy' => [ADVISABLE, ADMIN], 'tags' => ['checkout']],
['matches' => ['/.*/'], 'editableBy' => [ADVISABLE]] // catch-all
]Tags
Visual categorization of translation keys in the admin editor. Each tag has a label (itself a translatable key) and a CSS style (background-color, color). Tags defined in config:
| Tag | Color | Purpose |
|---|---|---|
checkout | Green (#4f911d) | Checkout flow strings |
customer | Yellow (#ffff00) | Customer account strings |
form_validation | Purple (#da0cff) | Form validation messages |
payways | Teal (#1c8597) | Payment method strings |
cart | Amber (#ffbf00) | Shopping cart strings |
bank_account_info* | Dark blue (#39445c) | Bank account details (per-bank variants) |
Visibility vs Editability
visibleBy: Controls whether a key appears in the listing at all. Keys invisible to the current role are filtered out entirely (hideInvisible()).editableBy: Controls whether visible keys can be modified. Non-editable keys are marked as "protected" and shown without Edit/Save/Revert buttons.
Default: all auth roles can see and edit, unless a rule restricts access. The catch-all rule restricts editing to ADVISABLE only.
Language Bundles
Bundles are defined in editable_languages.bundles config. Currently only the theme bundle is configured:
| Bundle | Base Files | User Files | Description |
|---|---|---|---|
theme | form_validation_lang (CI), adv_theme (ecommercen), {client}_theme (application) | _user_theme (application) | Storefront UI strings |
Each bundle entry specifies name (file name) and path (load path). The @client_views@ placeholder in bundle names is resolved at runtime to the client template folder name via Template::templateFolder().
Search Behavior
The AdvLanguageStorageModel::search() method supports two modes:
Fuzzy matching (unquoted terms): Character-by-character subsequence match. Each character of the search term must appear in order within the value (spaces stripped). This allows matching
chkbkagainstcheckout.back.Exact matching (quoted terms): Standard substring search using
mb_strpos. Wrap terms in double quotes for precise matching:"checkout"matches only values containing the literal word.
Multiple terms are AND-combined: all terms must match for a key to be included.
Integration with SaaS Wizard
The Adv_saas controller uses AdvLanguageStorageModel::setMessages() during the initial shop setup wizard to programmatically set bank account information translations. Writes are delegated to LanguageOverrideStore::applyChanges(), which performs the upsert + delete inside a DB transaction. The wizard normally runs AFTER php migrator.php migrate in the standard deploy flow; as a defence-in-depth measure, the store calls CI_DB::table_exists() once per request and short-circuits (logging a warning on writes, returning [] on reads) if language_overrides is missing. This keeps the wizard alive when run before migrations have been applied and prevents CI3's db_debug HTML+exit(8) from killing the bootstrap on a fresh install.
Business Rules
| Rule | Description |
|---|---|
| Base immutability | Platform base language files are never modified by the admin editor |
| Override-only editing | Admin edits are stored as rows in language_overrides; reverting deletes the override row |
| Write protection | Keys matching protected rules cannot be edited by unauthorized roles |
| Visibility filtering | Keys matching visibility rules are hidden from unauthorized roles |
| Rule ordering | First matching rule wins; catch-all must be last |
| Automatic revert on no-change | If a user sets a value identical to the base, it is treated as unset (the row is deleted) |
| Per-language storage | Overrides are stored per language idiom within each bundle ((bundle, lang, lang_key) unique key) |
| DB-backed storage | Overrides live in the language_overrides table; base strings still ship as PHP lang files |
| Cross-pod consistency | Every read goes to DB; an admin save on Pod A is visible on Pod B's very next request, no TTL window |
| Per-request memoisation | The store memoises slices for the duration of one request, so repeat reads of the same (bundle, lang) hit DB exactly once |
Admin Menu
Listed under the SETTINGS group in application/config/admin_menu.php:
- Route:
language_admin - Roles:
ADVISABLE,ADMIN - Icon and label use translatable keys:
admin.menu.language.icon,admin.menu.language.label
Key Files
| File | Purpose |
|---|---|
application/config/languages.php | Language config: abbreviations, URL mapping, editable_languages rules/bundles/tags |
application/config/container/language.php | DI: registers LanguageOverrideStore as a public service |
application/core/MY_Lang.php | URI language detection, load() with fallback, t() helper |
application/helpers/MY_language_helper.php | get_languages(), getAdminLanguages(), getLanguageValue() |
application/helpers/MY_url_helper.php | getMuiHomeUrls(), getMuiHomeUrl() -- language-prefixed URLs |
ecommercen/language/AdminLangLoader.php | Side-effect-free language loader for admin editor |
ecommercen/language/models/AdvLanguageStorageModel.php | Core translation storage model; delegates persistence to LanguageOverrideStore |
ecommercen/language/controllers/AdvLanguageAdmin.php | Admin listing page controller |
ecommercen/language/controllers/api/AdvApiLanguageAdmin.php | REST API for list/edit/revert |
ecommercen/core/Adv_base_controller.php | loadFrontLangFiles() -- language loading chain |
ecommercen/core/Adv_admin_controller.php | loadAdminLangFiles() + adminLanguages property |
src/Language/Storage/LanguageOverrideStore.php | DB-backed override store: getOverrides(), applyChanges(), static loadProxy() |
application/language/{idiom}/_user_theme_lang.php | 8 byte-identical proxy files; each calls LanguageOverrideStore::loadProxy(__FILE__) |
database/migrations/20260526120000_create_language_overrides.php | Phinx migration creating the language_overrides table |
database/migrations/20260526120001_migrate_user_langs_json_to_db.php | Phinx trigger for the one-shot JSON→DB patcher |
patches/MigrateUserLangsJsonToDb.php | Idempotent patcher: imports any legacy _user_langs.json into the DB and renames the source file to .imported |
assets/admin/js/languages/components/LanguagesListing.vue | Vue 2 SPA translation editor (466 lines) |
application/views/admin/language/list.php | Blade-style admin view mounting the Vue app |
Tests
Integration tests live in tests/Integration/Language/Storage/:
| Test file | Coverage |
|---|---|
LanguageOverrideStoreTest.php | Round-trips for getOverrides / applyChanges (create / upsert / unset / mixed / empty-input no-op), bundle+lang scoping, Unicode (Greek/CJK/Arabic), byte-exact uniqueness on Profile.Name vs profile.name, per-request memoisation (memo returns stale after direct DML; memo invalidated by applyChanges), 250-row batch upsert, loadProxy happy-path and bad-filename fallback |
MUI Pattern (Database Entity Translations)
Separate from the file-based i18n system, entity content translations use the MUI (Multi-Language Interface) database pattern:
- Companion
*_muitables store per-language content for entities (e.g.,products_mui,categories_mui) - Each MUI row has a
langcolumn (abbreviation likeel,en) - Admin forms iterate
$this->adminLanguagesto render input fields per language - Modern domain layer:
MuiRepositoryclasses insrc/Domains/*/Repository/handle MUI joins - REST layer:
MuiResource/MuiCollectionclasses expose MUI data per entity - The
admin_languagesconfig determines which languages appear in MUI forms
Known Issues & Security Gaps
- utf8 connection vs utf8mb4 table (low severity). The project DB connection is configured as
char_set: 'utf8'(utf8mb3, 3-byte) inapplication/config/database.php. Thelanguage_overridestable usesutf8mb4, so 4-byte codepoints (emoji, some CJK extensions, mathematical symbols) are silently substituted with?by the mysqli driver on the way in. The table tier is fine; widening the connection charset is a project-level config concern tracked separately. Greek, Arabic, and common CJK (all 3-byte) round-trip cleanly today.
Related Flows
- AD-13 Settings -- general shop configuration including language settings
- CF-01 Product Browsing -- storefront language switching via URL prefix
- SY-19 Config Registry -- registry system that backs some language config
- SY-23 MUI Translation Pattern -- the
*_muicompanion table pattern that implements database-level entity translations; AD-26 governs which languages are active in MUI forms
Wiki Guide: Language Files Guide -- developer reference for the language file system