tuzelko / yii2-localizable
Localization extension for Yii2 framework
Package info
github.com/TuzelKO/yii2-localizable
Type:yii2-extension
pkg:composer/tuzelko/yii2-localizable
Requires
- php: >=8.0
- yiisoft/yii2: ~2.0
Requires (Dev)
- phpunit/phpunit: ^9.0
README
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->titleresolves forYii::$app->languagewith automatic fallback to the default language - Single read/write virtual — get/set the whole
{default, values}structure through onelocalizationproperty - Self-managing rows — the behavior rebuilds the language rows on save and removes them on (hard) delete
- Form-ready validation —
LocalizationValidatorreuses 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
localizationis the structured object; the plurallocalizationsrelation 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.