CakePHP 4には、国際化と地域化のためにI18N
モジュールが提供されています。この機能を使って、マルチランゲージにチャレンジしたいと思います。
今回目指す仕様は
- アプリケーションで利用可能な言語を複数指定しておく
- データベースに登録される項目は、登録・編集画面で全ての言語を入力する
- ブラウザが送信してくる
Accept-Language
に従って、言語を切り替える
と、します。
基本のページを作成
まず初めに次のようなSQLを使って、テーブルを作成します。
Organizations.sql
CREATE TABLE IF NOT EXISTS `organizations` ( `id` INT NOT NULL AUTO_INCREMENT, `code` VARCHAR(8) BINARY NOT NULL, `name` VARCHAR(250) BINARY NOT NULL, `expiration` DATE NOT NULL, `credit_date` DATE NOT NULL, `billing_method` INT NOT NULL, PRIMARY KEY (`id`), UNIQUE INDEX `uk_organizations_01` (`code` ASC)) ENGINE = InnoDB;
続いてbake
してベースとなるページを作成します。
$ bin/cake bake all organizations ↵
これによって、次のようにファイルとフォルダが作成されました。
├ src │ ├ Controller │ │ └ OrganizationsController.php │ └ Model │ ├ Entity │ │ └ Organization.php │ └ Table │ └ OrganizationsTable.php ├ templates │ └ Organizations │ ├ add.php │ ├ edit.php │ ├ index.php │ └ view.php ├ tests │ ├ Fixture │ │ └ OrganizationsFixture.php │ └ TestCase │ ├ Controller │ │ └ OrganizationsControllerTest.php │ └ Model │ └ Table │ └ OrganizationsTableTest.php
マルチランゲージ化
次にこれらをマルチランゲージに対応させていきます。
まず、各翻訳データを格納するテーブルを作成します。テーブルを作成するSQLは、/config/schema/i18n.sql
にあります。
/config/schema/i18n.sql
CREATE TABLE i18n ( id int NOT NULL auto_increment, locale varchar(6) NOT NULL, model varchar(255) NOT NULL, foreign_key int(10) NOT NULL, field varchar(255) NOT NULL, content text, PRIMARY KEY (id), UNIQUE INDEX I18N_LOCALE_FIELD(locale, model, foreign_key, field), INDEX I18N_FIELD(model, foreign_key, field) );
翻訳データが必要なOrganizationsテーブル
にTranslate ビヘイビアーを追加します。
/src/Model/Table/OrganizationsTable.php
public function initialize(array $config): void { parent::initialize($config); $this->setTable('organizations'); $this->setDisplayField('name'); $this->setPrimaryKey('id'); $this->addBehavior('Translate', [ 'fields' => ['name'], 'allowEmptyTranslations' => false, ]); }
43~46行目を追加しました。
44行目で対象となるフィールドを配列で指定しています。
45行目で、空文字列の翻訳データが指定された場合に元データが書き換えられてしまうことを予防しています。
すべての翻訳を取得するべくエンティティーにトレイトを追加します。
/src/Model/Entity/Organization.php
use Cake\ORM\Behavior\Translate\TranslateTrait; use Cake\ORM\Entity;
class Organization extends Entity { use TranslateTrait;
6行目と21行目を追加しました。
これでトレイトを追加しています。
すべての翻訳を更新するべく設定に追加します。
protected $_accessible = [ 'code' => true, 'name' => true, 'expiration' => true, 'credit_date' => true, 'billing_method' => true, '_translations' => true, ];
38行目を追加しました。
これで翻訳データを更新対象に加えます。
※ 翻訳データのみを編集した際にデータベースが更新されないことがわかりましたので、38行目を追加しました。
対応できる言語の一覧を設定します。
/config/app_local.php
/* * Set the application-specific items. */ 'App' => [ // Declares the available locales. 'availableLocales' => ['ja', 'en_us', 'en'], ],
96~102行目を追加しました。
指定する値は、Accept-Language
で使用されるパターンと一致している必要があるようです。
日本語の場合ja_jp
としたいところですがja
と送信してくるので、そのようにしています。
また、ハイフン (-)
はアンダーバー (_)
に、大文字
は小文字
に統一する必要があるようです。
リクエストヘッダのAccept-Language
に基づいて言語を自動設定できるようにします。
/src/Application.php
use Cake\I18n\Middleware\LocaleSelectorMiddleware;
public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue { $middlewareQueue // Catch any exceptions in the lower layers, // and make an error page/response ->add(new ErrorHandlerMiddleware(Configure::read('Error'))) // Handle plugin/theme assets like CakePHP normally does. ->add(new AssetMiddleware([ 'cacheTime' => Configure::read('Asset.cacheTime'), ])) // Add routing middleware. // If you have a large number of routes connected, turning on routes // caching in production could improve performance. For that when // creating the middleware instance specify the cache config name by // using it's second constructor argument: // `new RoutingMiddleware($this, '_cake_routes_')` ->add(new RoutingMiddleware($this)) // enables automatic language switching from the "Accept-Language" // header sent by the browser. ->add(new LocaleSelectorMiddleware(Configure::read('App.availableLocales', [env('APP_DEFAULT_LOCALE', 'en_US')]))) ;
26行目と89行目を追加しました。
89行目でLocaleSelectorMiddleware
ミドルウェアを登録します。
パラメータには、対応可能な言語の一覧を渡します。
新規作成処理をマルチランゲージに対応させます。
/templates/Organizations/add.php
<fieldset> <legend><?= __('Add Organization') ?></legend> <?php foreach ($locales as $locale) { echo $this->Form->control('_translations.' . $locale . '.name', [ 'label' => __x('Organization field', 'Name') . ' (' . __x('locale', $locale) . ')', ]); } echo $this->Form->control('code'); echo $this->Form->control('expiration'); echo $this->Form->control('credit_date'); echo $this->Form->control('billing_method'); ?> </fieldset>
20~24行目のように変更しました。
それぞれの翻訳データを入力できるようにしています。
/src/Controller/OrganizationsController.php
/** * Add method * * @return \Cake\Http\Response|null|void Redirects on successful add, renders view otherwise. */ public function add() { $locales = Configure::read('App.availableLocales', [env('APP_DEFAULT_LOCALE', 'en_US')]); $organization = $this->Organizations->newEmptyEntity(); $organization->_translations = array(); foreach ($locales as $locale) { $organization->_translations[$locale] = $this->Organizations->newEmptyEntity(); } if ($this->request->is('post')) { $request = $this->request->getData(); $organization = $this->Organizations->patchEntity($organization, $request); $organization->set($request['_translations'][$this->Organizations->getLocale()]); if ($this->Organizations->save($organization)) { $this->Flash->success(__('The organization has been saved.')); return $this->redirect(['action' => 'index']); } $this->Flash->error(__('The organization could not be saved. Please, try again.')); } $this->set(compact('organization')); $this->set('locales', $locales); }
8行目と10~13行目、15行目、17行目、26行目を追加しました。
8行目では、対応可能な言語の一覧を取得しています。
10~13行目では、対応可能な言語を追加しています。
15行目では、画面から入力された値を配列変数に受け取っています。
17行目では、ブラウザで使用されている言語の翻訳データを元となるエンティティーのフィールドに転送しています。
26行目では、言語の一覧をビューにセットします。
※ 新規登録が行われないことがわかりましたので、10行目を追加しました。
/templates/Organizations/edit.php
<fieldset> <legend><?= __('Edit Organization') ?></legend> <?php foreach ($locales as $locale) { echo $this->Form->control('_translations.' . $locale . '.name', [ 'label' => __x('Organization field', 'Name') . ' (' . __x('locale', $locale) . ')', ]); } echo $this->Form->control('code'); echo $this->Form->control('expiration'); echo $this->Form->control('credit_date'); echo $this->Form->control('billing_method'); ?> </fieldset>
25~29行目のように変更しました。
それぞれの翻訳データを編集できるようにしています。
/src/Controller/OrganizationsController.php
/** * Edit method * * @param string|null $id Organization id. * @return \Cake\Http\Response|null|void Redirects on successful edit, renders view otherwise. * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. */ public function edit($id = null) { $locales = Configure::read('App.availableLocales', [env('APP_DEFAULT_LOCALE', 'en_US')]); $organization = $this->Organizations->get($id, [ 'finder' => 'translations', 'contain' => [], ]); foreach ($locales as $locale) { if (!array_key_exists($locale, $organization->_translations)) { $organization->_translations[$locale] = $this->Organizations->newEmptyEntity(); } } if ($this->request->is(['patch', 'post', 'put'])) { $organization = $this->Organizations->patchEntity($organization, $this->request->getData()); if ($this->Organizations->save($organization)) { $this->Flash->success(__('The organization has been saved.')); return $this->redirect(['action' => 'index']); } $this->Flash->error(__('The organization could not be saved. Please, try again.')); } $this->set(compact('organization')); $this->set('locales', $locales); }
10行目と12行目、15~19行目、30行目を追加しました。
10行目では、対応可能な言語の一覧を取得しています。
12行目では、登録されている全翻訳データを取得するように設定しています。
15~19行目では、対応可能な言語でありながら未登録の言語を追加しています。これにより、運用後に対応可能な言語を追加しても、容易に対応できるようになります。
30行目では、言語の一覧をビューにセットします。
/templates/Organizations/view.php
<tr> <th><?= __x('Organization field', 'Name') ?></th> <?php if (is_countable($locales)) { ?> <td> <table> <tr> <?php foreach ($locales as $locale) { ?> <th><?= __x('locale', $locale) ?></th> <?php } // foreach ($locales as $locale)?> </tr> <tr> <?php foreach ($locales as $locale) {?> <td><?= array_key_exists($locale, $organization->_translations) ? h($organization->_translations[$locale]->name) : "" ?></td> <?php } // foreach ($locales as $locale) ?> </tr> </table> </td> <?php } else { // if (is_countable($locales)) ?> <td><?= h($organization->name) ?></td> <?php } // if (is_countable($locales)) else ?> </tr>
27~44行目のように変更しました。
31~33行目では、言語のタイトルを表示しています。
36~38行目では、各言語毎に翻訳データを表示しています。
27行目では、PHP 7.2以降で出力されるワーニングを表示させないように配列であることを確認しています。
/src/Controller/OrganizationsController.php
/** * View method * * @param string|null $id Organization id. * @return \Cake\Http\Response|null|void Renders view * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. */ public function view($id = null) { $organization = $this->Organizations->get($id, [ 'finder' => 'translations', 'contain' => [], ]); $this->set('organization', $organization); $this->set('locales', Configure::read('App.availableLocales', [env('APP_DEFAULT_LOCALE', 'en_US')])); }
11行目と16行目を追加しました。
11行目では、登録されている全翻訳データを取得するように設定しています。
16行目では、言語の一覧をビューにセットします。
プログラム中に埋め込まれている文字列の翻訳データを作成します。
$ bin/cake i18n extract --exclude vendor,tests --overwrite ↵
次のファイルが作成されました。
├ resources │ └ locales │ ├ cake.pot │ └ default.pot
ここで作成されたdefault.pot
を各ロケールフォルダの下にdefault.po
としてコピーします。そして翻訳データを編集します。
/resources/locales/ja/default.po
# LANGUAGE translation of CakePHP Application # Copyright YEAR NAME# #, fuzzy msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "POT-Creation-Date: 2020-05-13 23:44+0000\n" "PO-Revision-Date: YYYY-mm-DD HH:MM+ZZZZ\n" "Last-Translator: NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" msgctxt "locale" msgid "en" msgstr "英語" msgctxt "locale" msgid "en_us" msgstr "アメリカ英語" msgctxt "locale" msgid "ja" msgstr "日本語" #: Controller/OrganizationsController.php:58 #: Controller/OrganizationsController.php:90 msgid "The organization has been saved." msgstr "組織が保存されました。" #: Controller/OrganizationsController.php:62 #: Controller/OrganizationsController.php:94 msgid "The organization could not be saved. Please, try again." msgstr "" #: Controller/OrganizationsController.php:112 msgid "The organization has been deleted." msgstr "" #: Controller/OrganizationsController.php:114 msgid "The organization could not be deleted. Please, try again." msgstr "組織を保存できませんでした。 もう一度やり直してください。" #: Organizations/add.php:10 #: Organizations/edit.php:10 #: Organizations/index.php:20 #: Organizations/view.php:10 msgid "Actions" msgstr "操作" #: Organizations/add.php:11 #: Organizations/edit.php:16 #: Organizations/view.php:13 msgid "List Organizations" msgstr "組織 一覧" #: Organizations/add.php:18 msgid "Add Organization" msgstr "組織 新規作成" #: Organizations/add.php:27 #: Organizations/edit.php:36 msgid "Submit" msgstr "登録" #: Organizations/edit.php:12 #: Organizations/index.php:35 msgid "Delete" msgstr "削除" #: Organizations/edit.php:14 #: Organizations/index.php:35 #: Organizations/view.php:12 msgid "Are you sure you want to delete # {0}?" msgstr "ID:{0}を本当に削除してよろしいですか?" #: Organizations/edit.php:23 #: Organizations/view.php:11 msgid "Edit Organization" msgstr "組織 編集" #: Organizations/index.php:8 #: Organizations/view.php:14 msgid "New Organization" msgstr "組織 新規作成" #: Organizations/index.php:9 msgid "Organizations" msgstr "組織" #: Organizations/index.php:33 msgid "View" msgstr "詳細" #: Organizations/index.php:34 msgid "Edit" msgstr "編集" #: Organizations/index.php:44 msgid "first" msgstr "最初" #: Organizations/index.php:45 msgid "previous" msgstr "前" #: Organizations/index.php:47 msgid "next" msgstr "次" #: Organizations/index.php:48 msgid "last" msgstr "最後" #: Organizations/index.php:50 msgid "Page {{page}} of {{pages}}, showing {{current}} record(s) out of {{count}} total" msgstr "" #: Organizations/view.php:12 msgid "Delete Organization" msgstr "組織 削除" #: Organizations/view.php:22 msgctxt "Organization field" msgid "Id" msgstr "ID" #: Organizations/view.php:26 msgctxt "Organization field" msgid "Name" msgstr "組織名" #: Organizations/view.php:47 msgctxt "Organization field" msgid "Code" msgstr "組織コード" #: Organizations/view.php:51 msgctxt "Organization field" msgid "Billing Method" msgstr "支払方法" #: Organizations/view.php:55 msgctxt "Organization field" msgid "Expiration" msgstr "有効期限" #: Organizations/view.php:59 msgctxt "Organization field" msgid "Credit Date" msgstr "与信期日" #: layout/error.php:40 msgid "Back" msgstr "戻る"
17~27行目では、利用可能な言語の表記を翻訳します。
また、エラーメッセージなど改善点が多数あると思いますがとりあえず良しとします。
他の言語に対しても同様に翻訳データを作成します。
各ロケールのフォルダは、/config/app_local.php
で指定したものにします。
日本語の場合、ja
とします。ja_JP
、ja_jp
では参照してくれませんでした。
以上で、マルチランゲージ化が行えました。
参考サイト
CakePHP 4 国際化と地域化
CakePHP 4 Translate
更新履歴
- 2020年6月9日
- addアクションでデータが追加できない不具合を修正しました。
- 翻訳データのみを編集した場合にデータベースが更新されない不具合を修正しました。