※2017/12/21追記 日付フォーマット処理にミスがあったため修正しました。

この記事は CakePHP Advent Calendar 2017 20日目の記事です。

皆さん、CakePHP使ってますか。
私はPHPで初めて触ったフレームワークということもあり、かなり思い入れがあります。
そんなCakePHPのバリデーションについて紹介します。

アジェンダ

  • この記事を書こうと思ったきっかけ
  • 最終的なゴール
  • カスタムバリデーションの実装
  • まとめ

この記事を書こうと思ったきっかけ

そもそもCakePHPを使うきっかけが、「就職してずっと使ってきたJava以外の言語を本格的に学びたい」という思いからでした。
仕事では簡単なツールにPHPを使っていたこともあり、CakePHP導入を決めました。

そして、実装を進めていたとき、

「バリデーションにフィールド名が出ないな…」

と思ったことがきっかけです。

最終的なゴール

以下のようなメッセージを出力します。

  • 「ID」は必須入力です。
  • 「名前」は空欄にできません。
  • 「年」は数値のみで入力してください。
  • 「生年月日」は「y/m/d」形式で入力してください。

上記は、デフォルトの翻訳ファイル(./bin/cake i18nで作成されたもの)を利用すると、以下のように出力されます。

  • このフィールドは必須です
  • このフィールドは空欄にできません
  • 与えられた値は無効です
  • 与えられた値は無効です

Oh...。

バリデーション機構の拡張

モデル・翻訳ファイルの定義

モデル・翻訳ファイルは以下のように定義します。
今回、翻訳ファイルはバリデーション用に切り出しました。

# App\Model\Table\TestTable.php
    /**
     * {@inheritdoc}
     */
    public function validationDefault(Validator $validator)
    {
        return $validator
            ->notEmpty(['name'])
            ->numeric('year')
            ->date('birthday', 'y/m/d');
    }
# App\Locale\ja_JP\validation.po
msgid "id"
msgstr "ID"

msgid "name"
msgstr "名前"

msgid "year"
msgstr "年"

msgid "birthday"
msgstr "誕生日"

msgid "field {0} is required"
msgstr "{0}は必須入力です。"

msgid "field {0} cannot be left empty"
msgstr "{0}は空欄にできません。"

msgid "field {0} is numeric value only"
msgstr "{0}は数値のみで入力してください。"

msgid "field {0} is alpha or numeric value only"
msgstr "{0}は英数字のみで入力してください。"

msgid "field {0} range is {1} - {2}"
msgstr "{0}は{1}~{2}の範囲で入力してください。"

msgid "field {0} length is {1} - {2}"
msgstr "{0}は{1}文字~{2}文字の範囲で入力してください。"

msgid "field {0} length is under the {1}"
msgstr "{0}は{1}文字以下で入力してください。"

msgid "field {0} length is over the {1}"
msgstr "{0}は{1}文字以上で入力してください。"

msgid "field {0} is {1} format only"
msgstr "{0}は「{1}」形式で入力してください。"

カスタムバリデータクラス追加

基本的な方針として、requiredempty以外のバリデーションルールはValidator::add()メソッドを利用するので、それを継承して調整します。
コードを見ていただければわかるとおり、requiredemptyは別途実装しています。
※もう少し良い実装方法があるはずなので、引き続き検討します…。

#MyValidator.php

namespace App\Validation;

use Cake\Validation\Validator;

/**
 * カスタムバリデータクラス
 */
class MyValidator extends Validator
{
    /**
     * 対象とするメッセージを定義
     * 英語でもフィールド名を表示できるよう、元のメッセージも変更しておく
     *
     * @var array
     */
    private $_messages = [
        'required' => 'field {0} is required',
        'notEmpty' => 'field {0} cannot be left empty',
        'numeric' => 'field {0} is numeric value only',
        'alphaNumeric' => 'field {0} is alpha or numeric value only',
        'lengthBetween' => 'field {0} length is {1} - {2}',
        'minLength' => 'field {0} length is over the {1}',
        'maxLength' => 'field {0} length is under the {1}',
        'range' => 'field {0} range is {1} - {2}',
        'invalidFormat' => 'field {0} is {1} format only',
    ];

    /**
     * `requirePresense()`および`allowEmpty()`・`notEmpty()`以外は原則このメソッドを通るので、
     * 上記で定義したメッセージを設定するよう修正
     */
    public function add($field, $name, $rule = [])
    {
        // メッセージが存在しなければ、ルールに該当するメッセージを定義から取得
        if (empty($rule['message'])) {
            $args = [__d('validation', $field)];
            if (isset($rule['rule']) && is_array($rule['rule'])) {
                $rules = $rule['rule'];
                array_shift($rules);
                $args = array_merge($args, $rules);
            }
            $rule['message'] = $this->getMessage($name, $args);
        }

        return parent::add($field, $name, $rule);
    }

    /**
     * `requirePresense()`および`allowEmpty()`・`notEmpty()`用
     * `errors()`の冒頭でデフォルトメッセージが固定宣言されているため、それを利用しないようここでメッセージを設定する
     * 本来は`$this->_allowEmptyMessages`および`$this->_allowEmptyMessages`へ追加する箇所で制御すべきだが、
     * その部分だけをオーバーライドできない(=今後追従できなくなる可能性がある)ため、ここで対応
     */
    protected function _convertValidatorToArray($fieldName, $defaults = [], $settings = [])
    {
        $results = parent::_convertValidatorToArray($fieldName, $defaults, $settings);
        foreach ($results as $name => $property) {
            // すでにメッセージがあれば何もしない
            if (!empty($property['message'])) {
                continue;
            }

            if (isset($property['mode'])) {
                // $propertyに`mode`があるのは`requirePresence()`利用時
                $results[$name]['message'] = $this->getMessage('required', __d('validation', $name));
            } elseif (isset($property['when'])) {
                // $propertyに`when`があるのは`allowEmpty()`および`notEmpty()`利用時
                $results[$name]['message'] = $this->getMessage('notEmpty', __d('validation', $name));
            }
        }

        return $results;
    }

    /**
     * 日付フィールド用
     */
    public function date($field, $formats = ['ymd'], $message = null, $when = null)
    {
        if (!is_array($formats)) {
            $formats = [$formats];
        }

        // 日付フォーマットをメッセージに渡す
        $args = [
            __d('validation', $field),
            implode(', ', $formats)
        ];
        $message = $this->getMessage('invalidFormat', $args);

        // フォーマットから「y」「M」「m」「d」以外を消し去る
        foreach ($formats as $key => $value) {
            $formats[$key] = preg_replace('/([^d|^m|^y|^M])/', '', $value);
        }

        return parent::date($field, $formats, $message, $when);
    }

    /**
     * メッセージのキーを取得する。     *
     * @param string $key メッセージのキー
     * @param null|array $args メッセージに設定する値
     * @return string|null Translated string.
     */
    public function getMessage(string $key, ...$args)
    {
        $message = $this->_messages[$key] ?? null;
        if (!$message) {
            return null;
        }

        // see \App\Locale\{code}\validation.po
        return __d('validation', $message, ...$args);
    }
}

利用するモデル・フォームに定義

作成したバリデーションを利用するよう、TableおよびFormで定義します。

  • Table
#App\Model\TestTable.php
    /**
     * カスタムバリデータクラス.
     *
     * @var string
     */
    protected $_validatorClass = '\App\Validation\Validator';
  • Form
# App\Form\TestForm
    /**
     * {@inheritDoc}
     */
    public function validator(Validator $validator = null)
    {
        if ($validator === null && empty($this->_validator)) {
            $validator = $this->_buildValidator(new MyValidator()); // 今回作成したバリデータ
        }

        return parent::validator($validator);
    }

以上で実装完了です。

まとめ

ErrorHelperなどは、規約に則っていれば特定ディレクトリにファイルを生成するだけで利用できます。
今回のバリデーションはこれができず、少し手間に感じました。
ただ、フレームワークのコードはわかりやすく、特に問題なく理解できたので、 自分でカスタマイズするのもそこまで難しくはありませんでした。

CakePHPは個人開発で一番使っているフレームワークで、先日出した初PRも無事マージされました。

これからも内容を理解しながらぼちぼち貢献していけたらなと思っています。
それでは、皆さんも良いCakePHPライフを!

一部記事用に修正しましたが、今回利用したソースコードの元はこちらにありますので、良ければご覧ください。