Skip to content

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:

  1. 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
  2. User dataset -- admin-editable overrides:
    • application/language/{idiom}/_user_theme_lang.php -- proxy file that delegates to LanguageOverrideStore::loadProxy(__FILE__)
    • Actual data stored in the language_overrides DB 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 LanguageOverrideStore

Supported 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 the key => value map 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 UPDATE for 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.php proxy. 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 via log_message('warning', …) when available (error_log fallback 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-default utf8mb4_unicode_ci would otherwise treat Profile.Name vs profile.name (or cafe.x vs café.x) as the same key on insert and silently collapse them under INSERT 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+. See 20260210120630_convert_db_mb_3_to_mb_4.php for 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):

  1. Deploy code.
  2. php migrator.php migrate -- creates language_overrides and runs MigrateUserLangsJsonToDb, seeding the table from any existing storage/user_langs/_user_langs.json. On success the legacy file is renamed to _user_langs.json.imported so a subsequent rollback + migrate cycle does not silently re-import overrides the admin has since reverted via the editor.
  3. 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_abbr map (e.g., el -> greek, en -> english)
  • Default language ignore: When lang_ignore is true, 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_Lang rewrites language and language_abbr config items and adjusts index_page for 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 languages

The t() global helper function (defined in MY_Lang.php) translates keys with vsprintf substitution:

php
function t(string $line, array $data = []): string

AdminLangLoader

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

ControllerFileLinesPurpose
AdvLanguageAdminecommercen/language/controllers/AdvLanguageAdmin.php26Renders the Vue listing page
AdvApiLanguageAdminecommercen/language/controllers/api/AdvApiLanguageAdmin.php168REST API: list, edit, revert
Language_adminapplication/modules/language/controllers/Language_admin.php6Application-level thin wrapper
Api_language_adminapplication/modules/language/controllers/api/Api_language_admin.php8Application-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:

MethodPurpose
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

MethodRouteHTTPDescription
list()language_admin/api/listGETPaginated listing with search, bundle/language selection, filters
edit()language_admin/api/edit/{lang}/{bundle}PUTSave translation overrides (key-value pairs)
revert()language_admin/api/revert/{lang}/{bundle}/{keys...}DELETERevert 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 of AUTH_ROLE_* constants that can modify matching keys
  • visibleBy: Array of AUTH_ROLE_* constants that can see matching keys
  • tags: 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:

TagColorPurpose
checkoutGreen (#4f911d)Checkout flow strings
customerYellow (#ffff00)Customer account strings
form_validationPurple (#da0cff)Form validation messages
paywaysTeal (#1c8597)Payment method strings
cartAmber (#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:

BundleBase FilesUser FilesDescription
themeform_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:

  1. 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 chkbk against checkout.back.

  2. 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

RuleDescription
Base immutabilityPlatform base language files are never modified by the admin editor
Override-only editingAdmin edits are stored as rows in language_overrides; reverting deletes the override row
Write protectionKeys matching protected rules cannot be edited by unauthorized roles
Visibility filteringKeys matching visibility rules are hidden from unauthorized roles
Rule orderingFirst matching rule wins; catch-all must be last
Automatic revert on no-changeIf a user sets a value identical to the base, it is treated as unset (the row is deleted)
Per-language storageOverrides are stored per language idiom within each bundle ((bundle, lang, lang_key) unique key)
DB-backed storageOverrides live in the language_overrides table; base strings still ship as PHP lang files
Cross-pod consistencyEvery read goes to DB; an admin save on Pod A is visible on Pod B's very next request, no TTL window
Per-request memoisationThe 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

FilePurpose
application/config/languages.phpLanguage config: abbreviations, URL mapping, editable_languages rules/bundles/tags
application/config/container/language.phpDI: registers LanguageOverrideStore as a public service
application/core/MY_Lang.phpURI language detection, load() with fallback, t() helper
application/helpers/MY_language_helper.phpget_languages(), getAdminLanguages(), getLanguageValue()
application/helpers/MY_url_helper.phpgetMuiHomeUrls(), getMuiHomeUrl() -- language-prefixed URLs
ecommercen/language/AdminLangLoader.phpSide-effect-free language loader for admin editor
ecommercen/language/models/AdvLanguageStorageModel.phpCore translation storage model; delegates persistence to LanguageOverrideStore
ecommercen/language/controllers/AdvLanguageAdmin.phpAdmin listing page controller
ecommercen/language/controllers/api/AdvApiLanguageAdmin.phpREST API for list/edit/revert
ecommercen/core/Adv_base_controller.phploadFrontLangFiles() -- language loading chain
ecommercen/core/Adv_admin_controller.phploadAdminLangFiles() + adminLanguages property
src/Language/Storage/LanguageOverrideStore.phpDB-backed override store: getOverrides(), applyChanges(), static loadProxy()
application/language/{idiom}/_user_theme_lang.php8 byte-identical proxy files; each calls LanguageOverrideStore::loadProxy(__FILE__)
database/migrations/20260526120000_create_language_overrides.phpPhinx migration creating the language_overrides table
database/migrations/20260526120001_migrate_user_langs_json_to_db.phpPhinx trigger for the one-shot JSON→DB patcher
patches/MigrateUserLangsJsonToDb.phpIdempotent patcher: imports any legacy _user_langs.json into the DB and renames the source file to .imported
assets/admin/js/languages/components/LanguagesListing.vueVue 2 SPA translation editor (466 lines)
application/views/admin/language/list.phpBlade-style admin view mounting the Vue app

Tests

Integration tests live in tests/Integration/Language/Storage/:

Test fileCoverage
LanguageOverrideStoreTest.phpRound-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 *_mui tables store per-language content for entities (e.g., products_mui, categories_mui)
  • Each MUI row has a lang column (abbreviation like el, en)
  • Admin forms iterate $this->adminLanguages to render input fields per language
  • Modern domain layer: MuiRepository classes in src/Domains/*/Repository/ handle MUI joins
  • REST layer: MuiResource / MuiCollection classes expose MUI data per entity
  • The admin_languages config 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) in application/config/database.php. The language_overrides table uses utf8mb4, 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.

Wiki Guide: Language Files Guide -- developer reference for the language file system