PHPのEnumで書いてあげると便利なメソッド・ダメなメソッド

LaravelでEnumを利用してわかった良いメソッド・ダメなメソッド
PHPのEnumで書いてあげると便利なメソッド・ダメなメソッド

はじめまして、ぎゅうです。

皆さん、Enumは利用されていますか?

PHP8.1から実装されたEnum。
他の言語では馴染み深いEnumですが、実はPHPではこれまでありませんでした。

私はLaravel8を利用した開発にあたって、このEnumをガッツリ使うことになりました。

実際に使ってみて、

  • Enumにある処理を書いてあげると使い勝手がよい
  • Enumに実装してはいけない処理
  • あれは議論が残るなあ…。

などなどが見えてきました。

これらを理解しておくと

  • 超便利なメソッドで開発を効率化したり
  • レビューする際に、悪いメソッドを書いていないか?チェックすることができます。

そこで今回、今回得られた知見をまとめ、皆さんに共有していきたいと思います。

Enumの基礎に関しては、下記の記事にまとめたので、ぜひ一読してみてくださいね。

目次

結論

基本的に書いて良いのは、下記2点のみ

  1. value系
  2. label系
  3. toArray系

これ以外は、実際に使用するService内で定義をすべき。

同じcase群をまとめると便利なケースがあるが、他のServiceでも利用しない限り不要。

これを破ったとき、ビジネスロジックをEnumで定義する問題が発生する。
チームでわかりやすい基準として、二つに絞ると良いでしょう。

書いてあげると超便利なメソッド5選

さて、本題の便利なメソッドを説明していきます。

まずは皆さん気になる便利メソッドの紹介です。

これから紹介するメソッドを土台に、あなた独自のEnumを構築していきましょう!

label()メソッド

Enumで定義した値を日本語名にしたいけど、どうすれば?

Enumで定義した値を日本語名で出力する機会もあると思います。

そのためのlabel()メソッドを作成します。

enum ExampleEnum: int
{
    case status1 = 1;
    case status2 = 2;
    case status3 = 3;
    case status4 = 4;

    public function label(): string
    {
        return match ($this) {
            self::status1 => 'ステータス1',
            self::status2 => 'ステータス2',
            self::status3 => 'ステータス3',
            self::status4 => 'ステータス4',
        };
    }
}

enumでlabel()を定義することで、日本語名を出力することができます。

ExampleEnum::from(1)->label()
// echo 'ステータス1'

日本語名を取得できるのも便利ですが、caseごとに日本語名があると仕様理解が早くなります。

$thisfrom()またはtryFrom()で取得したcaseになります。

補足になりますが、match()を利用することで、caseごとに出力する内容を変えています。

<?php
return  match (制約式) {
    単一の条件式 => 返却式,
    条件式1, 条件式2 => 返却式,
};

match()を知らなかった方は非常に便利なので、一度公式ドキュメントをみてください。

switch 文とは異なり、 弱い比較(==)ではなく、 型と値の一致チェック(===) に基づいて行われます。

これは超便利ですし、安心ですね。

あわせて読みたい
PHP: match - Manual PHP is a popular general-purpose scripting language that powers everything from your blog to the most popular websites in the world.

ユースケース:使い道は主にデータ整形です

Enumでlabel()メソッドを定義しておくと、データ整形の際に非常に便利です。

例えば、製品を扱うProductモデルのインスタンスである$productがあるとします。

class CreateProductsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('products', function (Blueprint $table) {
            $table->id();
            $table->string('name')->comment('商品名');
            $table->unsignedTinyInteger('status_id')->comment('ステータスid');
        });
    }

$product->status_idでステータスidを出力をします。

$product->status_id
// 1を出力

しかし、画面描画する際にはステータスのid番号ではなく、ステータス名(日本語)で描画したい。

そういった要望はよくあります。

そんな時にlabel()メソッドを活用します。

[
    return [
        'name' => $product->name; 
        'status' => ExampleEnum::tryFrom($product->status_id)?->label();  // ラベル名を取得
    ];
]

上記のようにjson生成の際など、array内でデータ整形するときにlabel()メソッドを活用することで、id番号から日本語名に変換します。

'status' => ExampleEnum::tryFrom($product->status_id)?->label(); 
// 'status' => 'ステータス1'

これでフロント側で日本語名に変換せず、そのまま描画することができて便利です。

ユースケース:管理者側とユーザー側で表示名を変えたいとき

同じstatusやroleでも管理者側とユーザー側で異なる名前で描画できないかな?

同じstatusやroleでも管理者側とユーザー側で異なる名前で描画したいこともあるでしょう。

そういった際は、match()defaultを利用するとシンプルになります。

例えば、下記のように

  • 管理者側のステータス名はadminLabel()で定義し、
  • ユーザー側のステータス名をuserLabel()で定義したとします。
enum ExampleEnum: int
{
    case status1 = 1;
    case status2 = 2;
    case status3 = 3;
    case status4 = 4;

    /**
     * 管理者側のラベル名を出力
     *
     * @return string
     */
    public function adminLabel(): string
    {
        return match ($this) {
            self::status1 => 'ステータス1',
            self::status2 => 'ステータス2',
            self::status3 => 'ステータス3',
            self::status4 => 'ステータス4',
        };
    }

    /**
     * ユーザー側のラベル名を出力
     *
     * @return string
     */
    public function userLabel(): string
    {
        return match ($this) {
            self::status1 => 'ステータス1だけ異なる名前',
            self::status2 => 'ステータス2',
            self::status3 => 'ステータス3',
            self::status4 => 'ステータス4',
        };
    }
}

パッと見て、どのステータスだけが名前が異なるのかわかりますか?

ちょっとわからないですよね。

これでは仕様がわかりづらいです。

そこで、match()defaultを利用してシンプルにします。

enum ExampleEnum: int
{
    case status1 = 1;
    case status2 = 2;
    case status3 = 3;
    case status4 = 4;

    /**
     * 管理者側のラベル名を出力
     *
     * @return string
     */
    public function adminLabel(): string
    {
        return match ($this) {
            self::status1 => 'ステータス1',
            self::status2 => 'ステータス2',
            self::status3 => 'ステータス3',
            self::status4 => 'ステータス4',
        };
    }

    /**
     * ユーザー側のラベル名を出力
     *
     * @return string
     */
    public function userLabel(): string
    {
        return match ($this) {
            self::status1 => 'ステータス1だけ異なる名前',
            default => $this->adminLabel(), // 管理者と同一の名前
        };
    }
}

この書き方だと、基本的にadminLabel()と同じ名前で、status1だけが名前が異なることがわかります。

このようにdefaultを利用すると、違いがわかりやすくなるので、おすすめです。

key()メソッド

label()では日本語のラベル名を取得しましたが、key()メソッドではkeyを取得します。

enum DocumentEnum: int
{
    case Petition = 1;
    case Approval = 2;
    case Proposal = 3;
    case Plan = 4;


    /**
     * 日本語のラベル名を出力
     *
     * @return string
     */
    public function label(): string
    {
        return match ($this) {
            self::Petition => '上申書',
            self::Approval => '稟議書',
            self::Proposal => '提案書',
            self::Plan     => '計画書',
        };
    }

    /**
     * keyを出力
     *
     * @return string
     */
    public function key(): string
    {
        return match ($this) {
            self::Petition => 'petition',
            self::Approval => 'approval',
            self::Proposal => 'proposal',
            self::Plan     => 'plan',
        };
    }
}

実行する際は下記のように実行します。

$key = DocumentEnum::tryFrom($document->role_id)?->key()
// 'petition'を出力

ユースケース:繰り返し処理が必要なデータ整形

例えば画面描画する際に、roleごとに指定した配置に描画したいとき、

keyでどのroleなのかわかると、画面表示がしやすくなります。

今回の例だと、ファイルの種類ごとに配置を指定したいなどです。

    public function toMap(): array
    {
        $documentArray = [];
        foreach($this->documents as $document) {

            $key = DocumentEnum::tryFrom($document->role_id)->key(); // key名を取得
  
            // keyごとにデータを配列に格納
            $documentArray[$key] = [
                'title' => $document->title,
                'file_name' => $document->file_name,
                'file_path' => $document->file_path,
                'created_at' => $document->created_at,
            ];
        };
            
        return $documentArray;
    }

このように、keyごとにarrayでデータを持たせることで、データを渡しやすくなりました

$data['petition']['title']
$data['petition']['file_name']
$data['petition']['file_pathitle']
$data['petition']['created_at']

$data['approval']['title']
$data['approval']['file_name']
$data['approval']['file_pathitle']
$data['approval']['created_at']

$data['proposal']['title']
$data['proposal']['file_name']
$data['proposal']['file_pathitle']
$data['proposal']['created_at']

$data['plan']['title']
$data['plan']['file_name']
$data['plan']['file_pathitle']
$data['plan']['created_at']

Vue.jsなどに値を渡す際にも非常に便利です。

ユースケース:validation

金額系で多いですが、一括で複数データを保存するとき、エラーメッセージをroleごとに分けたいことがありますよね。

そんなエラーメッセージの管理に便利です。

例えば、下記のようなinput nameがあるとします。

<input name="amounts[amounts][web_system][earnings]"/>
<input name="amounts[amounts][web_system][expenses]"/>

<input name="amounts[amounts][web_marketing][earnings]"/>
<input name="amounts[amounts][web_marketing][expenses]"/>

これをバリデーションしていきます。

普通なら、inputのnameごとに日本語名を設定していきます。

    public function attributes()
    {   
        return [
            'amounts.web_system.earnings'    => 'WEB開発部の売上'
            'amounts.web_system.expenses'    => 'WEB開発部の経費'
            'amounts.web_marketing.earnings' => 'WEBマーケティングの売上'
            'amounts.web_marketing.expenses' => 'WEBマーケティングの経費'
        ];
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {   
        return [
            'amounts.*.earnings' => ['required', 'integer'],
            'amounts.*.expenses' => ['integer', 'min:0', 'nullable'],
        ];
    }

項目が4つならいいですが、要件によっては10項目,20項目と多くなることもあります。

いちいち書くのも大変ですし、keyの名前を変更したい際にすべて手動でしなければいけません。
変更点が多くて大変です。

ちょっと手間ですよね。

しかし、enumでkey()を定義しておくとシンプルになります。

    public function attributes()
    {
        $attributes = [];
        
        foreach(DepartmentEnum::cases() as $department) {

            $attributes[] = [
                'amounts.' . $department->key() . '.earnings' => $department->label() . 'の売上', // key()とlabel()メソッドで対応
                'amounts.' . $department->key() . '.expenses' => $department->label() . 'の経費', // key()とlabel()メソッドで対応
            ];
        }
        
        return $attributes;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {   
        return [
            'amounts.*.earnings' => ['required', 'integer'],
            'amounts.*.expenses' => ['integer', 'min:0', 'nullable'],
        ];
    }

このように繰り返し処理をして、key()でinputのname名を指定し、label()日本語名をそれぞれ出力しています。

key()を利用することで、下記のようにname属性を割り当てています。

'amounts.' . $department->key() . '.earnings'
// 'amounts.web_system.earnings' 
// 'amounts.web_marketing.earnings' 
// 各keyを出力するので、記述量が減らすことができる。また、Enumでkey名を変更するだけですべて反映でき、保守性が高い。

種類ごとに日本語名があるはずなので、label()で日本語名でエラーを出力します。

$department->label() . 'の売上'
// WEB開発部の売上
// WEBマーケティングの売上

この二つを組み合わせて、各nameごとに日本語名を割り当てています。

    public function attributes()
    {
        $attributes = [];
        
        foreach(DepartmentEnum::cases() as $department) {

            $attributes[] = [
                'amounts.' . $department->key() . '.earnings' => $department->label() . 'の売上',
                'amounts.' . $department->key() . '.expenses' => $department->label() . 'の経費',
            ];
        }
        
        return $attributes;
    }

この数行だけで、どれだけ項目が増えても対応することができます。

keyやラベル名を変える際は、enumの値を変更するだけです。

toArray()

toArray()はそのままで、配列化処理になります。valuelabel()と活用し、配列を生成します。

enum DocumentEnum: int
{
    case Petition = 1;
    case Approval = 2;
    case Proposal = 3;
    case Plan = 4;


    /**
     * 日本語のラベル名を出力
     *
     * @return string
     */
    public function label(): string
    {
        return match ($this) {
            self::Petition => '上申書',
            self::Approval => '稟議書',
            self::Proposal => '提案書',
            self::Plan     => '計画書',
        };
    }

    /**
     * 配列化処理
     *
     * @return array
     */
    public function toArray(): array
    {
        return [
            'label' => $this->label(),
            'value' => $this->value,
        ];
    }
}

role_idから値とラベル名の両方を保持したい際に有効です。

DocumentEnum::tryFrom($document->role_id)->toArray();
// [ 'label' => '上申書', 'value' => 1];

// $data['document']['role']['label'];
// $data['document']['role']['value'];

casesToArray()

すべてのcaseのvalueとlabel()を保持したい際に便利です。selectboxなどではよく利用します。

enum DocumentEnum: int
{
    case Petition = 1;
    case Approval = 2;
    case Proposal = 3;
    case Plan = 4;


    /**
     * 日本語のラベル名を出力
     *
     * @return string
     */
    public function label(): string
    {
        return match ($this) {
            self::Petition => '上申書',
            self::Approval => '稟議書',
            self::Proposal => '提案書',
            self::Plan     => '計画書',
        };
    }

    /**
     * keyを出力
     *
     * @return string
     */
    public function key(): string
    {
        return match ($this) {
            self::Petition => 'petition',
            self::Approval => 'approval',
            self::Proposal => 'proposal',
            self::Plan     => 'plan',
        };
    }

    /**
     * casesを配列化
     */
    public static function casesToArray(): array
    {
        $casesArray = [];

        foreach (self::cases() as $case) {
            $key = $case->key();

            $casesArray[$key] = [
                'label' => $case->label(),
                'value' => $case->value,
            ];
        }
        return $casesArray;
    }
}

同じcase群の配列を取得

同じcase群を判定するメソッドをよく利用しますが、同じcase群の配列を取得する処理もよく使います。

例えば、限定公開・公開している記事を取得する機能だと、同じcase群のvalueが必要になります

enum PublicStatusEnum: int
{
    case Private = 1;
    case LimitedPublic = 2;
    case Public = 3;


    /**
     * 日本語のラベル名を出力
     *
     * @return string
     */
    public function label(): string
    {
        return match ($this) {
            self::Private       => '非公開',
            self::LimitedPublic => '限定公開',
            self::Public        => '一般公開',
        };
    }

    /**
     * 公開ステータス群のvalueを取得
     *
     * @return array
     */
    public static function getPublicValues(): array
    {
        return [
            self::LimitedPublic->value,
            self::Public->value,
        ];
    }
}

このgetPublicValues()があると、whereInなどで絞り込みやすい。

$articles->whereIn('status_id', PublicStatusEnum::getPublicValues());
// 公開している記事に絞り込み

同じようなステータス群がある場合は、非常に便利です。

今回の例だと、非公開ステータスを弾けばいいだけですが、

caseが10個~20個と非常に多い場合は必然的に利用していきます。

議論が残るメソッド

ここでは基本的にEnumで定義すべきでないメソッドについて説明します。

判定系メソッド

判定系のメソッドは基本的にEnumで定義すべきでないと考えています。

あれもこれもと定義していくと、気づけばEnumが肥大化します。

しかし、それらのメソッドは非常に限定的な箇所でしか使用しないケースが多いです。

それなら、Service内で判定処理を定義すべき

1つのメソッドでしか利用しない判定処理は、それを実際に利用するService層で定義した方が読みやすいです。

複数のServiceで利用するような、基盤となる判定処理以外は定義してはいけない。

根幹となる例

例えば、下記の3つのステータスがあるとします。

  • 非公開
  • 限定公開
  • 一般公開
enum PublicStatusEnum: int
{
    case Private = 1;
    case LimitedPublic = 2;
    case Public = 3;


    /**
     * 日本語のラベル名を出力
     *
     * @return string
     */
    public function label(): string
    {
        return match ($this) {
            self::Private       => '非公開',
            self::LimitedPublic => '限定公開',
            self::Public        => '一般公開',
        };
    }
}

このうち、限定公開一般公開のときだけ実行させたい処理があったとします。

そうすると、今のcaseが一般公開または限定公開なのかの判定をしないといけません。

そこで判定系のisPublic()で公開ステータスか判定します。

PublicStatusEnum::tryFrom($article->status_id)?->isPublic();
// true, false

このようなステータスの分類を判定するメソッドはまだ使っても良いでしょう。

実際に定義している箇所は下記になります。

enum PublicStatusEnum: int
{
    case Private = 1;
    case LimitedPublic = 2;
    case Public = 3;


    /**
     * 日本語のラベル名を出力
     *
     * @return string
     */
    public function label(): string
    {
        return match ($this) {
            self::Private       => '非公開',
            self::LimitedPublic => '限定公開',
            self::Public        => '一般公開',
        };
    }

    /**
     * 公開ステータスか判定
     *
     * @return boolean
     */
    public function isPublic(): bool
    {
        return match ($this) {
            self::LimitedPublic,
            self::Public => true,

            default => false,
        };
    }
}

定義してはいけない判定

ステータス判定以外は、判定してはいけません。

例えば、roleごとに保存条件が違うケースです。

この保存条件はビジネスロジックなので、Enumで定義すべきではないです。

例えば、下書き・公開ステータスで保存条件が異なるとします。

    /**
     * 商品の金額がnullでないか確認
     *
     * @param Product $product
     * @return boolean
     */
    public function checkNullPrice(Product $product): bool
    {
        return match($this) {
            self::Draft => true, // 下書きは金額がなくてもtrue
            default => ! is_null($product->price), // それ以外は金額が必要
        };
    }

この処理は金額があるか判定していますが、保存処理を担うServiceで定義すべきです。

このようにEnumで定義していない、別モデルの判定などは入れてはいけません

Enumの処理は、あくまでEnumで定義した値の範囲に限ります。

書いてはいけないメソッドの特徴

enumは非常に便利ですが、それゆえにメソッドを詰め込んでしまうケースも多いです。

何も考えずにenumと関係ある処理だからと関数を追加していくと

enumが肥大化し、可読性が下がるだけではなく、アーキテクチャが気付けば崩壊してしまいます。

それらを避けるためにも基準を設けることが重要になります。

そして、

  • どんな処理なら書いていいのか?
  • 書いてはいけない処理はなんなのか?

これを整理していきましょう。

enumのvalueとラベルを扱う関数なら書いてもよい

大きな基準として、enumのvalueを扱った関数やラベル系の関数は良いと思います。

例えば、

  • valueの配列を渡す
  • enumのcaseからkeyや日本語のラベル名を取得する関数
  • 同じcase群に該当するのか判定

これらは問題ないと思います。

この範囲ならenumで取り扱っている値の範囲です。valueもlabelもenumで定義しているものですから、その範囲のことはenumで書くべきだと思います

enumのvalueやlabel以外のデータやclassを扱う

valueやlabel系の範疇を超えた場合は、そのメソッドを記述すべきではないです。

データ整形をしていいのは、valueとlabel系のみです。

これらと異なるものを入れてしまうと、fat コントローラーのようにどんどん処理を追加していき、

本来サービス層に記述すべき処理も気づけば追加されてしまいます。

例えば、enumでファイルの種類を扱っていたとします。

enum DocumentEnum: int
{
    case Petition = 1;
    case Approval = 2;
    case Proposal = 3;
    case Plan = 4;


    /**
     * 日本語のラベル名を出力
     *
     * @return string
     */
    public function label(): string
    {
        return match ($this) {
            self::Petition=> '上申書',
            self::Approval => '稟議書',
            self::Proposal => '提案書',
            self::Plan     => '計画書',
        };
    }

    /**
     * casesを配列化
     */
    public static function casesToArray(): array
    {
        $casesArray = [];

        foreach (self::cases() as $case) {
            $key = $case->key();

            $casesArray[$key] = [
                'label' => $case->label(),
                'value' => $case->value,
            ];
        }
        return $casesArray;
    }

    /**
     * PDFのparamsを取得
     */
    public function getPdfParams(): array
    {
        return match ($this) {
            self::Petition => [
                'view' => view('pdf.petition'),
                'filename' => self::Petition->label(),
            ],
            self::Approval => [
                'view' => view('pdf.approval'),
                'filename' => self::Approval->label(),
            ],
            self::Proposal => [
                'view' => view('pdf.proposal'),
                'filename' => self::Proposal->label(),
            ],
            self::Plan => [
                'view' => view('pdf.plan'),
                'filename' => self::Plan->label(),
            ],
        };
    }
}

上記のようにgetPdfParams()という処理をつくり、caseごとに異なるviewやファイル名を取得できるとします。

一見、これは問題ないように見えますが、要件が複雑になると崩壊します

例えば、Petitionはステータスによって、3パターンのPDFを出力するという要件が追加されたらどうでしょうか?

今のこの処理を維持することはできなくなります。

そこで別の関数を作る必要が出てきます。

    /**
     * PDFのparamsを取得
     */
    public function getPdfParams(): array
    {
        return match ($this) {
            self::Petition => $this->getParamsPetition,
            self::Approval => [
                'view' => view('pdf.approval'),
                'filename' => self::Approval->label(),
            ],
            self::Proposal => [
                'view' => view('pdf.proposal'),
                'filename' => self::Proposal->label(),
            ],
            self::Plan => [
                'view' => view('pdf.plan'),
                'filename' => self::Plan->label(),
            ],
        };
    }

    /**
     * PDFのparamsを取得
     */
    public function getParamsPetition(): array
    {
        // ここで3パターンに分岐する処理を追加する
    }

同じように要件がハッキリしていくと、より複雑な分岐が必要になってきます。

例えば、提案書は企業の売上や従業員数によって変化するという仕様だったらどうでしょうか?

無数の処理が必要になりますよね。

    /**
     * PDFのparamsを取得
     */
    public function getPdfParams(): array
    {
        return match ($this) {
            self::Petition => $this->getParamsPetition,
            self::Approval => [
                'view' => view('pdf.approval'),
                'filename' => self::Approval->label(),
            ],
            self::Proposal => $this->getProposalPetition(),
            self::Plan => [
                'view' => view('pdf.plan'),
                'filename' => self::Plan->label(),
            ],
        };
    }

    /**
     * PDFのparamsを取得
     */
    public function getParamsPetition(): array
    {
        // ここで3パターンに分岐する処理を追加する
    }

    /**
     * PDFのparamsを取得
     */
    public function getProposalPetition(): array
    {
        // 従業員数や売上によって、さらに処理を分岐させていく。
    }

本来これらの処理はPDF用のService層で記述すべきであって、enumで書くべき処理ではありません。

確かに、ファイルの種類はDocumentEnumで管理していますが、あくまで取り扱うべきはcaseだけで、

それとは関係がないPDF関連の情報は保持すべきではないです。

こうならないためにもレビューの段階で修正してもらいましょう。

でないと、後で修正して余計な工数を割かなければいけなくなります。

まとめ

書いておくと便利なメソッドは

  • label系の処理
  • 配列化処理
  • case群の取得系・判定系

書いてはいけないメソッドは

  • valueやlabelの範疇を超える情報を扱う(Model, view, class, etc…)

レビューをする際は

  • valueやlabel系のメソッド以外はenum以外で書いてもらう。
  • その処理は本当にenumで書くべきか疑う(Service層やRepository層など他の箇所で記述すべきでないか確認する)。
  • 条件によって分岐する処理は記述すべきではない。仕様によって変化する系は、Service層で書くべき。

ということでした。

ぎゅう
WEBエンジニア
渋谷でWEBエンジニアとして働く。
LaravelとVue.jsをよく取り扱い、誰でも仕様が伝わるコードを書くことを得意とする。
先輩だろうがプルリクにコメントをして、リファクタしまくる仕様伝わるコード書くマン
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

目次
閉じる