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アクションでデータが追加できない不具合を修正しました。
- 翻訳データのみを編集した場合にデータベースが更新されない不具合を修正しました。