CakePHP4を試す


マルチランゲージに挑戦

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_JPja_jpでは参照してくれませんでした。

以上で、マルチランゲージ化が行えました。

参考サイト

CakePHP 4 国際化と地域化
CakePHP 4 Translate

更新履歴

2020年6月9日
addアクションでデータが追加できない不具合を修正しました。
翻訳データのみを編集した場合にデータベースが更新されない不具合を修正しました。