← Home ← Codex ← DEBT ← Engine
Browse by Category
+ added · updated 7d
← Back to glossary

Translatable Domain Model

i18n PHP 8.0+ Advanced
debt(d8/e9/b9/t7)
d8 Detectability Operational debt — how invisible misuse is to your safety net

Closest to 'silent in production until users hit it' (d9), but slightly better (d8) because detection_hints.automated is 'no' and the only signal is a code_pattern (single name/description columns, missing fallback handling) that no tool flags; it surfaces only when a non-default locale renders empty strings or 500s in production.

e9 Effort Remediation debt — work required to fix once spotted

Closest to 'architectural rework' (e9). The misconception states retrofitting translation 'changes entity identity, validation invariants, queries, and search indexing' — a structural refactor, not additive; quick_fix requires remodeling fields as per-locale collections with fallback chains across schema, repositories, read paths and search.

b9 Burden Structural debt — long-term weight of choosing wrong

Closest to 'defines the system's shape' (b9). applies_to spans web/cli/library and tags include domain-model/ddd; the translatability decision is load-bearing across the entire domain layer, repositories, and search — rewrite-or-live-with-it once entities carry locale-dependent text.

t7 Trap Cognitive debt — how counter-intuitive correct behaviour is

Closest to 'serious trap' (t7). The misconception is the canonical wrong belief: 'you can add multi-language support later without touching the model' — the obvious additive approach contradicts how the model actually behaves, and common_mistakes confirm devs duplicate whole entities and skip fallback strategy.

About DEBT scoring →

Also Known As

translatable entity multilingual domain model localizable model

TL;DR

A domain model designed from the start to carry per-locale content as first-class entity data, not as a translation layer bolted on later.

Explanation

A translatable domain model treats locale-varying content (names, descriptions, slugs, units, currencies) as a structural property of the entity rather than an afterthought. Instead of one flat `name` column, the model exposes translations keyed by locale, with a defined fallback strategy when a locale is missing. Two common patterns exist: a translation-table approach (a parent entity row plus a child `entity_translations` table with one row per locale) and an embedded approach (a value object holding a map of locale to text). The key design decision is which fields are translatable - some are (product description), some are locale-formatted but not translated (price, stored as a raw amount plus currency), and some are locale-independent (SKU, internal IDs). Building this distinction into the aggregate avoids the classic retrofit where a single-language schema is patched with parallel tables, string concatenation hacks, or duplicated rows per language. A well-designed translatable model also separates content translation from locale-aware formatting: the model holds the translated string, while presentation applies NumberFormatter or IntlDateFormatter at render time. Fallback rules belong in the domain (request fr-CA, fall back to fr, then to a default locale) so that every consumer behaves consistently. Query patterns matter: loading an entity should not trigger one extra query per translation (N+1), so translations are typically eager-loaded for the requested locale only. Treating translation as a domain concern keeps invariants enforceable - for example, requiring at least the default-locale value to be present before an entity can be published. Retrofitting i18n onto an anemic single-language model is one of the most expensive refactors a product faces, because it touches the schema, the repository, every read path, and search indexing simultaneously.

Common Misconception

You can add multi-language support later by adding columns or a translations table without touching the model. In practice translation changes entity identity, validation invariants, queries, and search indexing, so retrofitting is a structural refactor, not an additive one.

Why It Matters

Retrofitting i18n onto a single-language model forces simultaneous changes to schema, repositories, read paths, and search, which is far more expensive than designing translatable fields up front.

Common Mistakes

  • Storing one translation per row by duplicating the whole entity, which breaks referential integrity for non-translatable fields like SKU.
  • Treating every field as translatable, including IDs and raw prices that should be locale-formatted, not translated.
  • Loading translations with one query per locale or per row, producing N+1 queries on list pages.
  • Defining no fallback strategy, so a missing locale renders an empty string or a 500 instead of the default-locale value.
  • Mixing translation content with formatting, hardcoding currency or date strings inside the stored translated text.

Avoid When

  • The product is single-locale by contract and has no roadmap toward multiple languages.
  • Content is purely numeric or locale-independent (raw measurements, identifiers) where formatting alone suffices.
  • An off-the-shelf CMS already owns translation and the domain only consumes rendered output.

When To Use

  • The application serves users in multiple languages or plans to within its lifetime.
  • Entities carry user-facing text (names, descriptions) that differs per locale and needs fallback rules.
  • You need domain invariants like 'default-locale content required before publish' enforced consistently.
  • Search, slugs, or URLs must vary per locale and be queryable without N+1 loads.

Code Examples

✗ Vulnerable
// Single-language model retrofitted with hacks:
class Product {
    public string $name;        // only ever one language
    public string $description;
    public string $priceLabel;  // '$19.99' baked in
}

// Later 'fix': duplicate the row per language
// products: (id, lang, name, description, price_label)
// SKU and stock now duplicated and drift out of sync
$row = $db->query("SELECT * FROM products WHERE id=? AND lang=?", [$id, $lang]);
echo $row['name'] ?? '';     // missing locale -> blank
✓ Fixed
// Translatable by design:
final class Translation {
    public function __construct(public readonly string $name) {}
}

final class Product {
    public function __construct(
        public readonly string $sku,        // locale-independent
        public readonly int $priceCents,    // raw amount, formatted at render
        public readonly string $currency,   // 'EUR'
        /** @var array<string, Translation> */
        private array $translations,        // ['en' => Translation, 'de' => ...]
        private string $defaultLocale = 'en',
    ) {}

    public function name(string $locale): string {
        foreach ($this->fallbackChain($locale) as $l) {
            if (isset($this->translations[$l])) {
                return $this->translations[$l]->name;
            }
        }
        throw new \LogicException('Default locale translation required');
    }

    private function fallbackChain(string $locale): array {
        $base = explode('-', $locale)[0];
        return array_unique([$locale, $base, $this->defaultLocale]);
    }
}

// Presentation formats raw values, never the stored string:
$fmt = new NumberFormatter('de-DE', NumberFormatter::CURRENCY);
echo $product->name('de-DE');
echo $fmt->formatCurrency($product->priceCents / 100, $product->currency);

Added 12 Jun 2026
Views 72
Rate this term
No ratings yet
🤖 AI Guestbook educational data only
| |
Last 30 days
0 pings S 1 ping M 0 pings T 1 ping W 0 pings T 0 pings F 0 pings S 0 pings S 2 pings M 0 pings T 0 pings W 0 pings T 0 pings F 0 pings S 1 ping S 0 pings M 0 pings T 0 pings W 0 pings T 0 pings F 0 pings S 2 pings S 0 pings M 0 pings T 0 pings W 0 pings T 0 pings F 0 pings S 1 ping S 0 pings M
No pings yet today
Amazonbot 1
Google 7 SEMrush 6 PetalBot 6 Ahrefs 4 Brave Search 3 Amazonbot 3 Perplexity 2 Twitter/X 2 Applebot 2 Bing 2 Meta AI 1
crawler 37 crawler_json 1
DEV INTEL Tools & Severity
🟡 Medium ⚙ Fix effort: High
⚡ Quick Fix
Model translatable fields as a per-locale collection with an explicit fallback chain; keep raw values (price, SKU) locale-independent and format them at render time.
📦 Applies To
PHP 8.0+ web cli library
🔗 Prerequisites
🔍 Detection Hints
single name/description columns with parallel _translations tables added later; per-language row duplication; SELECT ... AND lang = ? without fallback handling
Auto-detectable: ✗ No
⚠ Related Problems
🤖 AI Agent
Confidence: Medium False Positives: Medium ✗ Manual fix Fix: High Context: Class Tests: Regenerate


✓ schema.org compliant