tuzelko/yii2-localizable

Localization extension for Yii2 framework

Maintainers

Package info

github.com/TuzelKO/yii2-localizable

Type:yii2-extension

pkg:composer/tuzelko/yii2-localizable

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-06-06 00:18 UTC

This package is auto-updated.

Last update: 2026-06-06 00:24:13 UTC


README

Project Status: Active Tests Latest Version PHP Version Total Downloads License

Localization extension for the Yii2 framework.

Stores localized attributes in a per-language related table (one row per language) instead of a JSON column, so the values stay plain, indexable columns you can search and sort on. A single behavior virtualizes those columns on the owner model, and a matching validator drops localization payloads straight into a form's rules().

Features

  • Real columns, not JSON — each translation is an ordinary column, so it is searchable, sortable, and indexable
  • Virtualized attributes$post->title resolves for Yii::$app->language with automatic fallback to the default language
  • Single read/write virtual — get/set the whole {default, values} structure through one localization property
  • Self-managing rows — the behavior rebuilds the language rows on save and removes them on (hard) delete
  • Form-ready validationLocalizationValidator reuses the row model's own rules; no duplicated field config
  • Translated messages out of the box — validation messages ship translated into many languages (the full Yii2 locale set), auto-registered via the extension bootstrap
  • Soft-delete friendly — soft-deleting an owner keeps its localizations (recoverable); only a hard delete clears them

Requirements

  • PHP >= 8.0
  • yiisoft/yii2 ~2.0

Installation

composer require tuzelko/yii2-localizable

How it works

The owner model holds no localized columns itself. Instead it owns a hasMany relation to a localization table that carries one row per language:

Owner (post) Localization (post_localization)
id id, post_id, lang, is_default, title, description, …

Exactly one row per owner is flagged is_default — that language is the fallback when the current one is missing. The behavior reads and writes that whole set through a single virtual property.

Quick start

1. Create the tables in your migration

$this->createTable('{{%post}}', [
    'id' => $this->primaryKey(),
    // ... your non-localized columns
]);

$this->createTable('{{%post_localization}}', [
    'id'          => $this->primaryKey(),
    'post_id'     => $this->integer()->notNull(),
    'lang'        => $this->string(5)->notNull(),
    'is_default'  => $this->boolean()->notNull()->defaultValue(false),
    'title'       => $this->string()->notNull(),
    'description' => $this->text()->null(),
]);

$this->addForeignKey('fk_post_localization_post', '{{%post_localization}}', 'post_id', '{{%post}}', 'id', 'CASCADE');
$this->createIndex('idx_post_localization_post_lang', '{{%post_localization}}', ['post_id', 'lang'], true);

2. The localization row model

Its rules() are the single source of field validation — the behavior runs the payload against them.

use yii\db\ActiveRecord;

class PostLocalization extends ActiveRecord
{
    public static function tableName(): string { return 'post_localization'; }

    public function rules(): array
    {
        return [
            [['lang', 'title'], 'required'],
            [['lang'], 'string', 'max' => 5],
            [['title'], 'string', 'max' => 255],
            [['description'], 'string'],
        ];
    }
}

3. Attach the behavior to the owner

The behavior reads the row class and FK link off the relation, so the relation must be a plain FK hasMany.

use tuzelko\yii\localization\LocalizableBehavior;
use yii\db\ActiveQuery;
use yii\db\ActiveRecord;

class Post extends ActiveRecord
{
    public static function tableName(): string { return 'post'; }

    public function behaviors(): array
    {
        return [
            'localizable' => [
                'class'      => LocalizableBehavior::class,
                'attributes' => ['title', 'description'],
            ],
        ];
    }

    public function getLocalizations(): ActiveQuery
    {
        return $this->hasMany(PostLocalization::class, ['post_id' => 'id']);
    }
}

That's it. $post->title is now a virtual, language-aware attribute, and $post->localization reads/writes the whole structure.

Reading localized attributes

$post = Post::findOne(1);

Yii::$app->language = 'ru';
$post->title;        // Russian title

Yii::$app->language = 'de'; // no German row
$post->title;        // falls back to the default language

The structured form is available through the singular localization virtual:

$post->localization;
// [
//     'default' => 'en',
//     'values'  => [
//         'en' => ['title' => 'Hello', 'description' => 'World'],
//         'ru' => ['title' => 'Привет', 'description' => 'Мир'],
//     ],
// ]

The singular localization is the structured object; the plural localizations relation is the raw row collection (use it for eager loading).

Writing localizations

Assign the {default, values} structure and save — the behavior rebuilds the rows in a transaction and sets is_default on the default language:

$post = new Post();
$post->localization = [
    'default' => 'en',
    'values'  => [
        'en' => ['title' => 'Hello', 'description' => 'World'],
        'ru' => ['title' => 'Привет', 'description' => 'Мир'],
    ],
];
$post->save();

Saving again with a different structure replaces the whole set. A new owner with no localizations assigned fails validation — at least one localization is required.

Validation in forms

Drop LocalizationValidator into a form's rules(), pointing it at a localizable model. It pulls the row class and field rules off that model's behavior, so the form restates neither:

use tuzelko\yii\localization\LocalizationValidator;
use yii\base\Model;

class PostForm extends Model
{
    public $localization;

    public function rules(): array
    {
        return [
            [['localization'], 'required'],
            [['localization'], LocalizationValidator::class, 'model' => new Post()],
        ];
    }
}

Being a plain rule, it keeps the usual knobs (on / when / skipOnEmpty / message). Field-level violations (a too-long or missing title) surface as validation errors (→ 400) instead of a save-time exception (→ 500).

Translated messages

The bundled validation messages ship translated into many languages — the full Yii2 locale set. The extension's composer bootstrap registers the localization message category automatically, so they work without any config in the consuming app — Yii::$app->language is all that matters.

Need to override a message or add a language? Define your own localization translation in the app config; the bootstrap never overwrites an existing one:

'components' => [
    'i18n' => [
        'translations' => [
            'localization' => [
                'class'    => \yii\i18n\PhpMessageSource::class,
                'basePath' => '@app/messages',
            ],
        ],
    ],
],

Deletion behaviour

The behavior owns the localization rows end to end. Hard-deleting an owner removes its rows automatically (hook it once, never forget cleanup). Soft-delete does not fire the delete event, so a soft-deleted owner keeps its localizations and they come back on restore.

Running tests

make test

Tests run inside Docker (PHP 8.0 + SQLite) with no local setup required.

License

MIT — see LICENSE.