Translations
Automatic LEFT JOIN with a translation table when a locale is set. Translated fields are included in query results alongside the main record.
Setup
Define $translationConfig in your repository:
class ProductRepository extends BaseRepository
{
protected ?array $translationConfig = [
'table' => 'product_translations',
'foreignKey' => 'product_id',
'fields' => ['name', 'description', 'h1', 'meta_title', 'meta_description'],
];
public function __construct(Connection $connection)
{
parent::__construct($connection, Product::class, 'products');
}
}Database Schema
Ensure your translation table exists:
CREATE TABLE product_translations (
id INT AUTO_INCREMENT PRIMARY KEY,
product_id INT NOT NULL,
locale VARCHAR(5) NOT NULL,
name VARCHAR(255),
description TEXT,
h1 VARCHAR(255),
meta_title VARCHAR(255),
meta_description TEXT,
UNIQUE(product_id, locale),
FOREIGN KEY (product_id) REFERENCES products(id)
);Config Reference
| Key | Type | Description |
|---|---|---|
table | string | Translation table name |
foreignKey | string | Foreign key column in translation table pointing to the main table |
fields | list<string> | Translated field names to select |
Basic Usage
Use withLocale() to include translated fields in your query:
// Fetch products with Ukrainian translations
$products = $repo->withLocale('uk')->findBy(['status' => 'active']);
// Each product now has translated fields (name, description, etc.)
echo $products[0]->name; // "Товар 1"withLocale() is a one-shot modifier — the locale resets after the query executes:
$repo->withLocale('uk')->findBy([]); // with translations
$repo->findBy([]); // without translationsThis follows the same pattern as with() for eager loading.
Methods
withLocale()
Set locale for the next query. Adds LEFT JOIN with translation table. Pass an optional second argument to enable a fallback locale.
public function withLocale(string $locale, ?string $fallbackLocale = null): static$product = $repo->withLocale('en')->find(1);
$products = $repo->withLocale('uk')->findBy(['status' => 'active']);
$products = $repo->withLocale('de')->findAll();
// With fallback: fields empty/missing in 'ru' are taken from 'uk'
$products = $repo->withLocale('ru', 'uk')->findBy([]);withoutLocale()
Clear a previously set locale before executing a query.
public function withoutLocale(): static$repo->withLocale('uk');
// Changed my mind...
$repo->withoutLocale();
$products = $repo->findBy([]); // No translation JOINHow It Works
When withLocale() is called, the next query will:
- LEFT JOIN the translation table on
foreignKey = primaryKey AND locale = :locale - Select all configured translated fields from the translation table
- Reset the locale after the query executes — so it never leaks into the next query
SELECT p.*, tr.name, tr.description
FROM products p
LEFT JOIN product_translations tr
ON tr.product_id = p.id AND tr.locale = 'uk'LEFT JOIN ensures that records without translations are still returned (translated fields will be null).
Fallback Locale
Pass a second locale to withLocale() to fill values that are missing or empty in the active locale from a fallback — useful for showing the default-language value until a translation is provided.
// Show Russian; for any field with no Russian value yet, use Ukrainian.
$products = $repo->withLocale('ru', 'uk')->findBy(['status' => 'active']);A second LEFT JOIN on the fallback locale is added and every translated field is COALESCEd over it:
SELECT p.*,
COALESCE(NULLIF(tr.name, ''), tr_fb.name) AS name,
COALESCE(NULLIF(tr.description, ''), tr_fb.description) AS description
FROM products p
LEFT JOIN product_translations tr
ON tr.product_id = p.id AND tr.locale = 'ru'
LEFT JOIN product_translations tr_fb
ON tr_fb.product_id = p.id AND tr_fb.locale = 'uk'Empty strings count as "missing"
NULLIF(tr.field, '') means an empty string in the active locale is treated as "no translation" and replaced by the fallback value. If you need to keep intentional empty values, don't store them as empty strings (use NULL), or omit the fallback.
- The fallback applies per field: a field that does have a value in the active locale is never overridden.
- When
$fallbackLocaleisnull, equal to$locale, or the repository has no$translationConfig, no second JOIN is added — behaviour is identical to a plainwithLocale($locale). - The fallback propagates to eager-loaded relations together with the active locale (see below).
- Fallback (and the empty-string check) targets text fields. Don't list numeric/date columns in
fieldswhen using a fallback —NULLIF(col, '')makes MySQL silently replace a legitimate0, and PostgreSQL rejects the query with a type error.
Writing Translations
Everything above is read-side (the LEFT JOIN). To create translation rows, use seedTranslations() — a helper for the common "fill every active locale on create" case.
seedTranslations()
Insert one translation row per locale from a single set of $values, skipping any locale that already has a row.
public function seedTranslations(int|string $id, array $locales, array $values): intParameters:
| Name | Type | Description |
|---|---|---|
$id | int|string | Foreign-key value linking the rows to the record (matches foreignKey) |
$locales | list<string> | Locales to seed |
$values | array<string, scalar|null> | Translated field => value pairs |
Returns: Number of rows actually inserted (locales that already had a row are skipped).
Example: seed all active locales right after creating the entity, so reads in any locale return a value immediately (the operator translates them later):
$product = $repo->create(['sku' => 'SKU-001', 'price' => 29.99]);
// Every locale starts from the operator's initial text.
$repo->seedTranslations($product->id, ['uk', 'en', 'de'], [
'name' => 'Product 1',
'description' => 'Initial description',
]);Only keys that appear in $translationConfig['fields'] are written — extra keys in $values are ignored, so you can pass a whole form payload:
// 'sku' and 'price' are not translated fields → silently skipped.
$repo->seedTranslations($id, ['uk'], [
'name' => 'Назва',
'sku' => 'SKU-001', // ignored
'price' => 29.99, // ignored
]);Idempotent — existing rows are preserved
Skipping relies on a UNIQUE(foreignKey, locale) constraint on the translation table (see the schema above). Re-running seedTranslations() for the same record never overwrites an existing locale — it only fills in the missing ones. Use it to back-fill locales added after the entity was created:
// Later a new locale goes live — back-fill only it, existing rows untouched.
$repo->seedTranslations($id, ['uk', 'en', 'de', 'pl'], $values); // inserts 'pl' onlySingle-locale edits
seedTranslations() is for the create/back-fill case. To update one locale's text, write the translation row directly — e.g. a dedicated translation repository with updateBy(['product_id' => $id, 'locale' => 'uk'], $values).
Cross-platform note
The skip is emitted per platform: INSERT IGNORE (MySQL/MariaDB), INSERT OR IGNORE (SQLite), and INSERT … ON CONFLICT DO NOTHING (PostgreSQL and others). All three require the UNIQUE(foreignKey, locale) constraint to recognise a duplicate.
When the repository has no $translationConfig, or $locales is empty, or none of $values matches a translated field, the call is a no-op and returns 0.
Model Setup
Your model should accept translated fields as nullable:
class Product
{
public function __construct(
public int $id,
public string $sku,
public float $price,
public ?string $name = null,
public ?string $description = null,
public ?string $h1 = null,
public ?string $meta_title = null,
public ?string $meta_description = null,
) {}
public static function fromArray(array $data): self
{
return new self(
(int) $data['id'],
$data['sku'],
(float) $data['price'],
$data['name'] ?? null,
$data['description'] ?? null,
$data['h1'] ?? null,
$data['meta_title'] ?? null,
$data['meta_description'] ?? null,
);
}
}Practical Examples
Multi-language Product Catalog
class ProductRepository extends BaseRepository
{
protected ?array $translationConfig = [
'table' => 'product_translations',
'foreignKey' => 'product_id',
'fields' => ['name', 'description', 'h1', 'meta_title', 'meta_description'],
];
public function __construct(Connection $connection)
{
parent::__construct($connection, Product::class, 'products');
}
public function findActiveForLocale(string $locale): array
{
return $this->withLocale($locale)->findBy(
['status' => 'active'],
['sort_order' => 'ASC']
);
}
public function findBySlugForLocale(string $slug, string $locale): ?Product
{
return $this->withLocale($locale)->findOneBy(['slug' => $slug]);
}
}Combining with Eager Loading
Translations work with eager loading. When withLocale() is used together with with(), the locale — and the fallback locale, if any — automatically propagates to all related repositories:
$products = $repo
->with(['category', 'tags'])
->withLocale('ru', 'uk')
->findBy(['status' => 'active']);
// Both products AND their related categories/tags get translated fields,
// resolved with the same 'ru' → 'uk' fallback rule
// (if their repositories also have $translationConfig defined)Locale Propagation
Each related repository must have its own $translationConfig to include translations. Repositories without it will simply ignore the propagated locale.
Filtering Through Relation EXISTS
When you filter a parent repository by a translated field on a related repository (relation.translatedField), the EXISTS subquery automatically LEFT JOINs the related repo's translation table and resolves the field against it — provided the active locale is set on the parent.
// User has many Articles, Article has translationConfig with 'title'
$users->withLocale('uk')->findBy(['articles.title' => 'Привіт']);Generated SQL (simplified):
SELECT u.* FROM users u
WHERE EXISTS (
SELECT 1
FROM articles r
LEFT JOIN article_translations r_t
ON r_t.article_id = r.id AND r_t.locale = :locale
WHERE r.user_id = u.id
AND r_t.title = :value
)Non-translated fields on the same relation still target the relation table directly (e.g. articles.slug). Mixing both in one criteria works:
$users->withLocale('uk')->findBy([
'articles.title' => 'Привіт', // → translation alias
'articles.slug' => 'hello', // → relation alias
]);Fallback does not apply to filtering
A fallback locale affects only the values returned in the SELECT. Relation EXISTS filters match strictly against the requested locale: a row whose translated field exists only in the fallback locale will not match relation.translatedField, even though it would be shown via the fallback. This is intentional — a filter is a predicate over the actual data for the requested locale, not over the presentation value.
See Criteria → Relation Filters for relation criteria details.
Combining with Soft Delete
Translations work with soft delete:
class ArticleRepository extends BaseRepository
{
protected ?string $deletedAtColumn = 'deleted_at';
protected ?array $translationConfig = [
'table' => 'article_translations',
'foreignKey' => 'article_id',
'fields' => ['title', 'body'],
];
}
// Only active articles, with translations
$articles = $repo->withLocale('en')->findBy([]);Without Translation Config
If $translationConfig is not set, withLocale() and withoutLocale() are safe no-ops:
class LogRepository extends BaseRepository
{
// No $translationConfig
}
$repo->withLocale('uk')->findBy([]); // Works fine, no JOIN added