【完全ガイド】PHP Enumを徹底解説!基本から応用まで5つの実践例

PHPプログラマーなら誰もが経験したことがあるでしょう。ステータスや種別などの固定値を扱う際、クラス定数や配列を使って何とか型安全性を確保しようと奮闘する日々を。「この値が適切かどうか、実行時までわからない」「IDEの補完が効かず、タイプミスに気づけない」といった問題に頭を悩ませてきたのではないでしょうか。

PHP 8.1で導入された「Enum(列挙型)」は、そんな長年の課題を解決する待望の機能です。強力な型チェックと優れた開発者体験を両立しながら、コードの品質と保守性を大きく向上させる可能性を秘めています。

本記事では、PHP Enumの基本概念から実践的な活用法まで、5つの具体的な実装パターンを通して徹底解説します。Enumをマスターすることで、より堅牢なアプリケーション設計が可能になり、バグの少ないクリーンなコードへの道が開けるでしょう。フレームワーク連携からリファクタリング手法まで、現場で即役立つ知識を網羅的にお届けします。

PHPの表現力と型安全性を次のレベルへ引き上げる旅に、ぜひご参加ください。

目次

目次へ

PHP Enumとは?PHP 8.1で導入された新機能の全容

PHP 8.1の目玉機能として2021年11月に正式リリースされた「Enum(列挙型)」は、多くのPHPエンジニアが長年待ち望んでいた機能です。シンプルに言えば、Enumは関連する定数の集合を型として定義できる言語機能です。しかし、その意義と可能性は単なる定数群をはるかに超えています。

Enumの核心は「限定された値のセット」を型として扱えることにあります。例えば、曜日や方角、HTTPステータスコードなど、あらかじめ決まった値の集合を厳密に定義し、型安全に扱えるようになります。

// 基本的なEnumの例
enum Status
{
    case DRAFT;
    case PUBLISHED;
    case ARCHIVED;
}

// 使用例
function updateArticleStatus(Article $article, Status $newStatus): void
{
    $article->status = $newStatus;
    $article->save();
}

// 呼び出し例
updateArticleStatus($article, Status::PUBLISHED);

PHPのEnumには「Pure Enum(純粋列挙型)」と「Backed Enum(バックド列挙型)」の2種類があります。Pure Enumはケース名のみを持ち、Backed Enumは各ケースに文字列や整数値を関連付けることができます。この柔軟性により、データベースやAPIとの連携など、様々な場面で活用できます。

// Backed Enumの例 (文字列値を持つ)
enum StatusWithValue: string
{
    case DRAFT = 'draft';
    case PUBLISHED = 'published';
    case ARCHIVED = 'archived';
}

PHP Enumの特筆すべき点は、単なる定数集合ではなく、完全なオブジェクト指向機能を備えていることです。メソッド、インターフェース実装、トレイト使用など、クラスと同様の機能を活用できます。これにより、値だけでなく振る舞いも含めたドメインの概念を表現可能になりました。

PHPの型システムが年々強化される流れの中で、Enumは「正しいデータ構造で正しい値だけを扱う」という現代的なプログラミングパラダイムを強力に後押しします。従来のPHPでは実現が難しかった型安全性と表現力を両立させた画期的な機能と言えるでしょう。

次のセクションでは、PHPの型システム全体におけるEnumの位置づけと、他言語との違いについて掘り下げていきます。

PHPの型システム進化の集大成としてのEnum

PHP言語の歴史を振り返ると、型システムの段階的な進化が一貫して見られます。かつての「ウェブ用スクリプト言語」から「エンタープライズ開発に堪える言語」へと成長する過程で、型安全性の向上は最も重要な進化の一つでした。

PHP 5時代は「動的型付け」を前面に押し出した柔軟な言語でしたが、大規模開発における予測可能性や保守性の課題が浮き彫りになっていました。PHP 7からは型宣言の強化が本格化し、以下のような段階的進化を遂げています。

バージョン導入された型関連機能
PHP 7.0スカラー型の引数宣言、戻り値の型宣言、strict_typesディレクティブ
PHP 7.1Nullable型、void戻り値型
PHP 7.4プロパティ型宣言、共変戻り値型・反変引数型
PHP 8.0Union型、コンストラクタプロパティプロモーション、Match式
PHP 8.1Enum型、Readonly プロパティ、Intersection型
PHP 8.2Readonly クラス、DNF型

この流れを見ると、PHP 8.1のEnum型は単なる新機能ではなく、長年続いた型システム進化の集大成と位置づけられます。Enumは「値の種類を制限する型」という新たな表現力をPHPにもたらし、以下のような恩恵をもたらします:

  1. 静的解析のさらなる強化 – PHPStanやPsalmなどの静的解析ツールがEnumを認識し、より精度の高い分析が可能に
  2. ドメインモデルの正確な表現 – ビジネスルールに基づく制約を型システムレベルで表現可能に
  3. コード補完の向上 – IDEが利用可能な値を正確に把握し、開発者体験を向上
  4. バグの早期発見 – コンパイル時(実行前)に型の不一致を検出可能
// PHP 5時代: 文字列で状態管理(型安全性なし)
$status = 'published'; // 'publshed'とタイプミスしても検出不可

// PHP 7+クラス定数: 若干改善するも型としては文字列のまま
$status = ArticleStatus::PUBLISHED; // 定数は存在チェックされるが型は文字列

// PHP 8.1 Enum: 完全な型安全性
$status = Status::PUBLISHED; // StatusというEnum型として厳密に扱われる

PHP Enumの登場により、型システムは「何が入るか」だけでなく「どのような値が許容されるか」までを厳密に定義できるようになりました。これはPHPが目指してきた「柔軟さを保ちつつ堅牢性を高める」という進化の重要なマイルストーンと言えるでしょう。

他言語のEnumとPHP Enumの違い

プログラミング言語によってEnumの実装方法は大きく異なります。PHP Enumを深く理解するには、他言語との比較が役立ちます。ここでは主要言語のEnum実装とPHPの違いを見ていきましょう。

主要言語のEnum実装比較

言語Enum特性値の型メソッド定義インターフェース実装
Javaクラスベース任意のオブジェクト
C#値型(struct)整数型のみ❌(拡張メソッドは可)
TypeScript数値/文字列マッピング数値/文字列
Rust代数的データ型複合型・ジェネリック✅(Trait)
SwiftAssociated Values複合型✅(Protocol)
PHPクラスに近い特殊型文字列/整数(Backed)or なし(Pure)

PHPのEnumは、JavaのEnumに近い実装方法を採用していますが、いくつかの重要な違いがあります。

Java vs PHP:

// Java Enum
enum Status {
    DRAFT("draft"), PUBLISHED("published"), ARCHIVED("archived");
    
    private final String value;
    
    Status(String value) {
        this.value = value;
    }
    
    public String getValue() {
        return value;
    }
}
// PHP Enum (同等の機能)
enum Status: string {
    case DRAFT = 'draft';
    case PUBLISHED = 'published';
    case ARCHIVED = 'archived';
    
    public function getValue(): string {
        return $this->value;
    }
}

PHPのEnumは、Javaほど柔軟なコンストラクタを持たない代わりに、コードがより簡潔になっています。

TypeScript vs PHP:

// TypeScript Enum
enum Status {
    DRAFT = 'draft',
    PUBLISHED = 'published',
    ARCHIVED = 'archived'
}
// PHP Backed Enum
enum Status: string {
    case DRAFT = 'draft';
    case PUBLISHED = 'published';
    case ARCHIVED = 'archived';
}

表面上は類似していますが、TypeScriptのEnumはコンパイル時に単なるオブジェクトに変換されるのに対し、PHPのEnumは実行時にも型として機能します。

特筆すべきPHP Enumの特徴:

  1. メソッド実装の柔軟性 – PHPのEnumはJavaに近い形でメソッドを定義できます
  2. トレイト使用可能 – 共通の機能をトレイトとして実装し、複数のEnumで再利用できます
  3. クラスとの親和性 – クラスのプロパティ型、引数型、戻り値型としてシームレスに使用できます
  4. Pure/Backed二種類の実装 – ユースケースに応じて適切な形式を選択できます
  5. シリアライズ制限 – 他言語と異なり、PHPのEnumはシリアライズできません(内部実装の制約)

PHP Enumの実装は、柔軟なオブジェクト機能を持ちながらも、列挙型として絞り込まれた特性を持つよう設計されています。他の言語からPHPに移行する開発者は、特にシリアライズの制限とコンストラクタの制約を理解しておくことが重要です。

PHPのEnumは、静的型付け言語の堅牢さと動的言語の表現力をバランスよく取り入れた実装となっており、古くからこの機能を持っていた言語の良い部分を参考にしつつ、PHP特有のエコシステムに適合するよう設計されています。

PHP Enumの基本的な使い方と構文

PHP Enumを実際のコードで使いこなすために、基本的な構文とパターンを押さえておきましょう。Enumの宣言から使用まで、段階的に見ていきます。

Enumの基本的な宣言方法

PHP Enumの宣言はenumキーワードを使用します。最もシンプルな形式は以下の通りです:

enum PaymentStatus
{
    case PENDING;
    case COMPLETED;
    case FAILED;
    case REFUNDED;
}

このようなケース値のみを持つ列挙型を「Pure Enum(純粋列挙型)」と呼びます。Pure Enumはケース自体が値となり、文字列や整数値を持ちません。

Enumの基本的な使用方法

定義したEnumは以下のように使用します:

// Enumの値を変数に代入
$status = PaymentStatus::COMPLETED;

// 関数の引数として使用(型宣言可能)
function processPayment(Order $order, PaymentStatus $status): void
{
    // Enumのケースで条件分岐
    if ($status === PaymentStatus::COMPLETED) {
        $order->markAsPaid();
    }
    
    // match式との組み合わせ(PHP 8.0以降)
    $message = match($status) {
        PaymentStatus::PENDING => '支払い待ち',
        PaymentStatus::COMPLETED => '支払い完了',
        PaymentStatus::FAILED => '支払い失敗',
        PaymentStatus::REFUNDED => '返金済み',
    };
    
    echo $message;
}

// 関数呼び出し
processPayment($order, PaymentStatus::COMPLETED);

Enumの組み込みメソッドと特性

PHPのEnumには便利な組み込み機能があります:

// 全てのケースを取得
$allStatuses = PaymentStatus::cases();
// 結果: [PaymentStatus::PENDING, PaymentStatus::COMPLETED, PaymentStatus::FAILED, PaymentStatus::REFUNDED]

// ケース名を取得(Pure Enum)
$statusName = PaymentStatus::COMPLETED->name;
// 結果: "COMPLETED"

Backed Enumの宣言と使用

Pure Enumに加えて、文字列や整数値を持つ「Backed Enum(バックド列挙型)」も定義できます:

// 文字列値を持つBacked Enum
enum PaymentMethod: string
{
    case CREDIT_CARD = 'credit_card';
    case BANK_TRANSFER = 'bank_transfer';
    case PAYPAL = 'paypal';
    case CRYPTO = 'cryptocurrency';
}

// 整数値を持つBacked Enum
enum HttpStatus: int
{
    case OK = 200;
    case CREATED = 201;
    case BAD_REQUEST = 400;
    case NOT_FOUND = 404;
    case SERVER_ERROR = 500;
}

Backed Enumでは、値を取得したり値からケースを逆引きしたりできます:

// 値を取得
$methodValue = PaymentMethod::PAYPAL->value; // 'paypal'

// 値からケースを取得
$method = PaymentMethod::from('paypal'); // PaymentMethod::PAYPAL
$status = HttpStatus::from(404); // HttpStatus::NOT_FOUND

// 安全に値からケースを取得(存在しない場合はnull)
$method = PaymentMethod::tryFrom('unknown_method'); // null

Backed Enumを使用することで、データベースの文字列値やAPI通信の値と、タイプセーフなEnum型との間で変換が容易になります。

Enumのメソッド定義

PHPのEnumはクラスに近い性質を持ち、メソッドを定義できます:

enum PaymentStatus
{
    case PENDING;
    case COMPLETED;
    case FAILED;
    case REFUNDED;
    
    // インスタンスメソッド
    public function getLabel(): string
    {
        return match($this) {
            self::PENDING => '支払い待ち',
            self::COMPLETED => '支払い完了',
            self::FAILED => '支払い失敗',
            self::REFUNDED => '返金済み',
        };
    }
    
    // 静的メソッド
    public static function getDefaultStatus(): self
    {
        return self::PENDING;
    }
    
    // ヘルパーメソッド
    public function canRefund(): bool
    {
        return $this === self::COMPLETED;
    }
}

// メソッドの使用
$status = PaymentStatus::COMPLETED;
echo $status->getLabel(); // '支払い完了'
$canRefund = $status->canRefund(); // true

これらの基本構文を押さえておくことで、PHPのEnumを効果的に活用できるようになります。次のセクションでは、Pure EnumとBacked Enumの違いについてさらに詳しく掘り下げていきます。

Pure EnumとBacked Enumの違いと宣言方法

PHPのEnumには「Pure Enum(純粋列挙型)」と「Backed Enum(バックド列挙型)」の2つの種類があります。どちらを選ぶかによって、使用できる機能や適したユースケースが異なるため、両者の違いを正確に理解しておくことが重要です。

Pure Enum vs Backed Enum: 基本的な違い

特性Pure EnumBacked Enum
宣言方法enum Name {...}enum Name: string/int {...}
ケースの値値を持たない(ケース自体が値)文字列または整数値を持つ
型変換文字列/整数への直接変換不可->valueで基本型に変換可能
値からの生成from()/tryFrom()は使用不可from()/tryFrom()で値から生成可能
主なユースケース内部的な型安全性が重要な場合外部データとの連携が必要な場合

Pure Enumの宣言と特徴

Pure Enumは値を持たず、ケース自体が一意の値となります:

// Pure Enumの宣言
enum UserRole
{
    case ADMIN;      // 値を持たない
    case EDITOR;
    case AUTHOR;
    case SUBSCRIBER;
}

// 使用例
$role = UserRole::ADMIN;

// 比較
if ($role === UserRole::ADMIN) {
    // 管理者権限の処理
}

// 全ケース取得
$allRoles = UserRole::cases();

// ケース名の取得
$roleName = $role->name;  // "ADMIN"

Pure Enumの特徴は単純さとシンプルさです。内部的な列挙型として使用する場合や、値よりも型安全性が重要な場合に適しています。

Backed Enumの宣言と特徴

Backed Enumは各ケースに対応する値(文字列または整数)を持ちます:

// 文字列値を持つBacked Enum
enum UserRoleWithValue: string
{
    case ADMIN = 'administrator';     // 文字列値を持つ
    case EDITOR = 'editor';
    case AUTHOR = 'author';
    case SUBSCRIBER = 'subscriber';
}

// 整数値を持つBacked Enum
enum PermissionLevel: int
{
    case READ = 1;
    case WRITE = 2;
    case DELETE = 4;
    case ALL = 7;  // ビットフラグの組み合わせも可能
}

// 値の取得
$roleValue = UserRoleWithValue::ADMIN->value;  // "administrator"

// 値からEnumを生成(存在しない値の場合は例外発生)
$role = UserRoleWithValue::from('editor');  // UserRoleWithValue::EDITOR

// 安全に値からEnumを生成(存在しない値の場合はnull)
$role = UserRoleWithValue::tryFrom('unknown');  // null

Backed Enumは外部システムとの連携に特に適しています:

  • データベースに文字列/整数として保存
  • JSONやAPIレスポンスでの値の受け渡し
  • 既存コードからの移行時の互換性維持

どちらを選ぶべきか?

選択の基準は以下の通りです:

  1. Pure Enum を選ぶ場合:
    • 完全に内部的な使用で、外部との連携が不要
    • コードの型安全性と明確さが最優先
    • シンプルさを重視
  2. Backed Enum を選ぶ場合:
    • データベースとの連携がある
    • APIやフォームからの入力値と連携する
    • 既存の文字列/整数定数からの移行
    • 人間可読な値が必要

多くの実際のアプリケーションでは、Backed Enumの柔軟性が役立つケースが多いですが、純粋にドメインモデルを表現するだけならPure Enumでも十分です。プロジェクトの要件に応じて適切なタイプを選択しましょう。

Enumのメンバーとケース定数の定義方法

Enumの効果的な活用には、適切なケース定数の定義が不可欠です。ここでは、Enumのメンバー定義におけるベストプラクティスと設計パターンを紹介します。

ケース名の命名規則

Enumのケース名の標準的な命名規則は大文字のスネークケースです。これはPHPの定数命名規則に合わせたもので、一目でケース定数であることが分かります:

enum OrderStatus
{
    case AWAITING_PAYMENT;    // 推奨:大文字スネークケース
    case PROCESSING;
    case SHIPPED;
    case DELIVERED;
    // case awaitingPayment;  // 非推奨:キャメルケースや小文字
}

Backed Enumの場合、値の命名は使用コンテキストに適した形式を選びます:

enum OrderStatus: string
{
    // データベースやAPIで使う場合はスネークケースが一般的
    case AWAITING_PAYMENT = 'awaiting_payment';
    case PROCESSING = 'processing';
    
    // JSONやフロントエンドとの連携ではキャメルケースも
    // case AWAITING_PAYMENT = 'awaitingPayment';
}

関連するケースのグループ化

関連するケースを論理的にグループ化すると、コードの理解が容易になります。コメントやメソッドを使って関連性を表現しましょう:

enum OrderStatus
{
    // 注文受付中ステータス
    case CART;
    case CHECKOUT;
    case AWAITING_PAYMENT;
    
    // 処理中ステータス
    case PROCESSING;
    case PACKING;
    case SHIPPED;
    
    // 完了ステータス
    case DELIVERED;
    case COMPLETED;
    case CANCELLED;
    case REFUNDED;
    
    // グループ化を助けるヘルパーメソッド
    public function isActive(): bool
    {
        return in_array($this, [
            self::PROCESSING,
            self::PACKING,
            self::SHIPPED
        ]);
    }
    
    public function isCompleted(): bool
    {
        return in_array($this, [
            self::DELIVERED,
            self::COMPLETED
        ]);
    }
    
    public function isTerminated(): bool
    {
        return in_array($this, [
            self::CANCELLED,
            self::REFUNDED
        ]);
    }
}

// 使用例
$status = OrderStatus::PROCESSING;
if ($status->isActive()) {
    // 処理中の注文に対する操作
}

フラグ値としてのEnum定義

整数型のBacked Enumを使用すると、ビットフラグのような複合的な権限設定を表現できます:

enum Permission: int
{
    case READ = 1;     // 2^0 = 1
    case WRITE = 2;    // 2^1 = 2
    case DELETE = 4;   // 2^2 = 4
    case ADMIN = 8;    // 2^3 = 8
    
    // 組み合わせた権限
    case EDITOR = 3;   // READ + WRITE = 1 + 2 = 3
    case MANAGER = 7;  // READ + WRITE + DELETE = 1 + 2 + 4 = 7
    case SUPER = 15;   // READ + WRITE + DELETE + ADMIN = 1 + 2 + 4 + 8 = 15
    
    // 特定の権限を持っているか確認するヘルパーメソッド
    public function hasPermission(self $permission): bool
    {
        return ($this->value & $permission->value) === $permission->value;
    }
}

// 使用例
$role = Permission::EDITOR;
if ($role->hasPermission(Permission::WRITE)) {  // true
    // 書き込み権限がある処理
}
if ($role->hasPermission(Permission::DELETE)) { // false
    // 削除権限がある処理
}

メンバー間の関係を表現するテクニック

Enumのケース間の関係をメソッドで表現することで、ドメインロジックをEnumにカプセル化できます:

enum TaskStatus
{
    case TODO;
    case IN_PROGRESS;
    case REVIEW;
    case DONE;
    
    // 次のステータスを取得
    public function next(): ?self
    {
        return match($this) {
            self::TODO => self::IN_PROGRESS,
            self::IN_PROGRESS => self::REVIEW,
            self::REVIEW => self::DONE,
            self::DONE => null,
        };
    }
    
    // 前のステータスを取得
    public function previous(): ?self
    {
        return match($this) {
            self::TODO => null,
            self::IN_PROGRESS => self::TODO,
            self::REVIEW => self::IN_PROGRESS,
            self::DONE => self::REVIEW,
        };
    }
    
    // 次へ進めるかのチェック
    public function canTransitionTo(self $newStatus): bool
    {
        return $this->next() === $newStatus;
    }
}

// 使用例
$currentStatus = TaskStatus::IN_PROGRESS;
$nextStatus = $currentStatus->next(); // TaskStatus::REVIEW

このようにケース定数を単なる値の集合ではなく、関連性を持ったドメインモデルとして設計することで、Enumの真価を発揮できます。メソッドを追加することで、ケース定数に振る舞いを持たせ、より表現力豊かなコードを実現しましょう。

Enumに実装できるインターフェースとトレイト

PHPのEnumはクラスに近い特性を持っており、インターフェースの実装とトレイトの使用が可能です。これによりEnumに強力な機能を追加し、より表現力豊かなドメインモデルを構築できます。

インターフェースの実装

Enumはクラスと同様にインターフェースを実装できます:

// カスタムインターフェースの定義
interface LabelProvider
{
    public function getLabel(): string;
    public function getDescription(): string;
}

// Enumでインターフェースを実装
enum PaymentStatus implements LabelProvider
{
    case PENDING;
    case PROCESSING;
    case COMPLETED;
    case FAILED;
    
    // インターフェースメソッドの実装
    public function getLabel(): string
    {
        return match($this) {
            self::PENDING => '支払い待ち',
            self::PROCESSING => '処理中',
            self::COMPLETED => '完了',
            self::FAILED => '失敗',
        };
    }
    
    public function getDescription(): string
    {
        return match($this) {
            self::PENDING => 'お支払いが確認できるまでお待ちください。',
            self::PROCESSING => 'お支払いを処理中です。',
            self::COMPLETED => 'お支払いが完了しました。',
            self::FAILED => 'お支払い処理に失敗しました。',
        };
    }
}

インターフェース実装の主なメリットは以下の通りです:

  1. 型の統一: 異なるEnum型でも同じインターフェースを実装することで、共通の振る舞いを保証
  2. ポリモーフィズムの活用: インターフェース型として扱うことで、異なるEnumを同じように処理
  3. 関心の分離: ドメインロジックとプレゼンテーションロジックを分けられる
  4. テスト容易性: モックやスタブの作成が容易になる

インターフェースを活用した実践例

複数のEnumに同じインターフェースを実装することで、一貫した処理が可能になります:

// 共通インターフェース
interface Displayable
{
    public function display(): string;
    public function getColor(): string;
}

// 注文ステータスEnum
enum OrderStatus implements Displayable
{
    case NEW;
    case PROCESSING;
    case SHIPPED;
    case DELIVERED;
    
    public function display(): string
    {
        return match($this) {
            self::NEW => '新規注文',
            self::PROCESSING => '処理中',
            self::SHIPPED => '発送済み',
            self::DELIVERED => '配達完了',
        };
    }
    
    public function getColor(): string
    {
        return match($this) {
            self::NEW => 'blue',
            self::PROCESSING => 'orange',
            self::SHIPPED => 'purple',
            self::DELIVERED => 'green',
        };
    }
}

// 支払いステータスEnum
enum PaymentStatus implements Displayable
{
    case PENDING;
    case PAID;
    case REFUNDED;
    
    public function display(): string
    {
        return match($this) {
            self::PENDING => '支払い待ち',
            self::PAID => '支払い済み',
            self::REFUNDED => '返金済み',
        };
    }
    
    public function getColor(): string
    {
        return match($this) {
            self::PENDING => 'yellow',
            self::PAID => 'green',
            self::REFUNDED => 'red',
        };
    }
}

// 両方のEnumを同じように扱える
function renderStatusBadge(Displayable $status): string
{
    return sprintf(
        '<span class="badge badge-%s">%s</span>',
        $status->getColor(),
        $status->display()
    );
}

// 使用例
echo renderStatusBadge(OrderStatus::PROCESSING);
echo renderStatusBadge(PaymentStatus::PAID);

トレイトの活用

トレイトを使用すると、複数のEnumで共通の機能を再利用できます:

// 共通機能をトレイトとして定義
trait EnumMapTrait
{
    // 全てのケースを連想配列にマッピング
    public static function toArray(): array
    {
        $result = [];
        foreach (self::cases() as $case) {
            $key = $case->name;
            $value = $case instanceof \BackedEnum ? $case->value : $case->name;
            $result[$key] = $value;
        }
        return $result;
    }
    
    // 選択肢として使用するための配列を取得
    public static function getOptions(): array
    {
        $options = [];
        foreach (self::cases() as $case) {
            $value = $case instanceof \BackedEnum ? $case->value : $case->name;
            $label = method_exists($case, 'getLabel') ? $case->getLabel() : $case->name;
            $options[$value] = $label;
        }
        return $options;
    }
}

// トレイトを使用したEnum
enum ProductCategory: string
{
    use EnumMapTrait;
    
    case ELECTRONICS = 'electronics';
    case CLOTHING = 'clothing';
    case BOOKS = 'books';
    case FOOD = 'food';
    
    public function getLabel(): string
    {
        return match($this) {
            self::ELECTRONICS => '電化製品',
            self::CLOTHING => '衣類',
            self::BOOKS => '書籍',
            self::FOOD => '食品',
        };
    }
}

// トレイトのメソッドを使用
$categories = ProductCategory::toArray();
$options = ProductCategory::getOptions();

注意点と制限事項

Enumにインターフェースやトレイトを実装する際の制限事項:

  1. コンストラクタの制限 – Enumはコンストラクタにアクセス修飾子を指定できない
  2. 継承不可 – EnumはクラスやEnumを継承できない
  3. 抽象メソッド制限 – Enumにトレイトによる抽象メソッドを強制できない場合がある

これらの制限を念頭に置いて、適切なインターフェースとトレイトの設計を行いましょう。

従来の定数と比較:PHP Enumのメリットと限界

PHP Enumの価値を正しく理解するためには、従来のPHPでの定数表現と比較することが重要です。長年のPHP開発では、列挙型の代替として様々な方法が使われてきました。

従来のPHPでの定数表現方法

PHP 8.1以前では、列挙型の値を表現するために以下のような方法が使われていました:

1. グローバル定数(define)

// グローバル定数による実装
define('STATUS_DRAFT', 'draft');
define('STATUS_PUBLISHED', 'published');
define('STATUS_ARCHIVED', 'archived');

// 使用例
function updateStatus($status) {
    if ($status === STATUS_PUBLISHED) {
        // 処理
    }
}

2. クラス定数

// クラス定数による実装
class ArticleStatus {
    public const DRAFT = 'draft';
    public const PUBLISHED = 'published';
    public const ARCHIVED = 'archived';
    
    // バリデーション用の補助メソッド
    public static function isValid(string $status): bool {
        return in_array($status, [
            self::DRAFT,
            self::PUBLISHED,
            self::ARCHIVED
        ]);
    }
    
    // ラベル取得用のヘルパーメソッド
    public static function getLabel(string $status): string {
        return match($status) {
            self::DRAFT => '下書き',
            self::PUBLISHED => '公開済み',
            self::ARCHIVED => 'アーカイブ済み',
            default => '不明'
        };
    }
}

// 使用例
if (ArticleStatus::isValid($status)) {
    echo ArticleStatus::getLabel($status);
}

3. 疑似Enum的なクラス

// オブジェクト指向で疑似Enumを実装
class Status {
    private string $value;
    
    private function __construct(string $value) {
        $this->value = $value;
    }
    
    public static function DRAFT(): self {
        return new self('draft');
    }
    
    public static function PUBLISHED(): self {
        return new self('published');
    }
    
    public static function ARCHIVED(): self {
        return new self('archived');
    }
    
    public function getValue(): string {
        return $this->value;
    }
    
    public function equals(self $other): bool {
        return $this->value === $other->value;
    }
}

// 使用例
$status = Status::PUBLISHED();
if ($status->equals(Status::PUBLISHED())) {
    // 処理
}

PHP Enumと従来手法の比較

実際のコードで比較してみましょう:

// PHP 8.1以前:クラス定数での実装
class OldPaymentStatus {
    public const PENDING = 'pending';
    public const COMPLETED = 'completed';
    public const FAILED = 'failed';
    
    public static function getAll(): array {
        return [self::PENDING, self::COMPLETED, self::FAILED];
    }
    
    public static function isValid(string $status): bool {
        return in_array($status, self::getAll());
    }
}

// PHP 8.1以降:Enumでの実装
enum PaymentStatus: string {
    case PENDING = 'pending';
    case COMPLETED = 'completed';
    case FAILED = 'failed';
    
    public function getLabel(): string {
        return match($this) {
            self::PENDING => '処理中',
            self::COMPLETED => '完了',
            self::FAILED => '失敗'
        };
    }
}

// 従来の使用法
$oldStatus = OldPaymentStatus::PENDING;
if ($oldStatus === OldPaymentStatus::PENDING) {
    // 文字列同士の比較(型の保証なし)
}

// Enumの使用法
$status = PaymentStatus::PENDING;
if ($status === PaymentStatus::PENDING) {
    // オブジェクト同士の比較(型の保証あり)
}

PHP Enumの主なメリット

  1. 真の型安全性
    • 引数や戻り値に具体的なEnum型を指定可能
    • 不正な値の代入や比較を完全に防止
    • PHPStanやPsalmなどの静的解析ツールとの親和性向上
  2. IDEサポートの充実
    • コード補完で利用可能なすべてのケースを表示
    • リファクタリング時の一括変更が容易
    • 使用箇所の追跡が簡単
  3. ドメインロジックのカプセル化
    • Enumにメソッドを追加してロジックを集約
    • 表示用の文字列や振る舞いをEnum内に閉じ込め
    • 関連するロジックの散在を防止
  4. コードの明確さと自己文書化
    • パラメータの型からどのような値が期待されるか明確
    • 意図が明確になり、コメントの必要性が低減
    • コードの可読性と保守性が向上
  5. バリデーションの簡素化
    • from/tryFromメソッドによる入力値の検証
    • 不正値の早期検出が容易
    • 条件分岐の漏れを防止

PHP Enumの制限事項と注意点

  1. PHP 8.1以上の要件
    • 古いPHPバージョンをサポートする必要があるプロジェクトでは使用不可
    • 段階的移行の難しさ
  2. シリアライズの制限
    • Enumオブジェクトは直接シリアライズできない
    • データベース保存時に値への変換が必要
    • セッションへの保存に工夫が必要
  3. パフォーマンスへの影響
    • 単純な文字列定数と比較すると若干のオーバーヘッド
    • 大量のEnum操作が必要な場面では考慮が必要
  4. リフレクションの制限
    • 動的なEnum生成ができない
    • コンストラクタやプロパティに制約あり
  5. 既存コードからの移行コスト
    • 広範囲に使用されている定数の移行には労力が必要
    • データベースやAPIとの連携部分の修正が必要

Enumのメリットは制限事項を大きく上回ることが多く、新規プロジェクトやPHP 8.1以上を使用するプロジェクトでは積極的に採用すべき機能です。既存プロジェクトでは、重要度の高いドメインモデルから段階的に移行することで、効果的にEnumの恩恵を受けられるでしょう。

従来の定数と比較:PHP Enumのメリットと限界

PHP Enumの価値を正しく理解するためには、従来のPHPでの定数表現と比較することが重要です。長年のPHP開発では、列挙型の代替として様々な方法が使われてきました。

従来のPHPでの定数表現方法

PHP 8.1以前では、列挙型の値を表現するために以下のような方法が使われていました:

1. グローバル定数(define)

// グローバル定数による実装
define('STATUS_DRAFT', 'draft');
define('STATUS_PUBLISHED', 'published');
define('STATUS_ARCHIVED', 'archived');

// 使用例
function updateStatus($status) {
    if ($status === STATUS_PUBLISHED) {
        // 処理
    }
}

2. クラス定数

// クラス定数による実装
class ArticleStatus {
    public const DRAFT = 'draft';
    public const PUBLISHED = 'published';
    public const ARCHIVED = 'archived';
    
    // バリデーション用の補助メソッド
    public static function isValid(string $status): bool {
        return in_array($status, [
            self::DRAFT,
            self::PUBLISHED,
            self::ARCHIVED
        ]);
    }
    
    // ラベル取得用のヘルパーメソッド
    public static function getLabel(string $status): string {
        return match($status) {
            self::DRAFT => '下書き',
            self::PUBLISHED => '公開済み',
            self::ARCHIVED => 'アーカイブ済み',
            default => '不明'
        };
    }
}

// 使用例
if (ArticleStatus::isValid($status)) {
    echo ArticleStatus::getLabel($status);
}

3. 疑似Enum的なクラス

// オブジェクト指向で疑似Enumを実装
class Status {
    private string $value;
    
    private function __construct(string $value) {
        $this->value = $value;
    }
    
    public static function DRAFT(): self {
        return new self('draft');
    }
    
    public static function PUBLISHED(): self {
        return new self('published');
    }
    
    public static function ARCHIVED(): self {
        return new self('archived');
    }
    
    public function getValue(): string {
        return $this->value;
    }
    
    public function equals(self $other): bool {
        return $this->value === $other->value;
    }
}

// 使用例
$status = Status::PUBLISHED();
if ($status->equals(Status::PUBLISHED())) {
    // 処理
}

PHP Enumと従来手法の比較

実際のコードで比較してみましょう:

// PHP 8.1以前:クラス定数での実装
class OldPaymentStatus {
    public const PENDING = 'pending';
    public const COMPLETED = 'completed';
    public const FAILED = 'failed';
    
    public static function getAll(): array {
        return [self::PENDING, self::COMPLETED, self::FAILED];
    }
    
    public static function isValid(string $status): bool {
        return in_array($status, self::getAll());
    }
}

// PHP 8.1以降:Enumでの実装
enum PaymentStatus: string {
    case PENDING = 'pending';
    case COMPLETED = 'completed';
    case FAILED = 'failed';
    
    public function getLabel(): string {
        return match($this) {
            self::PENDING => '処理中',
            self::COMPLETED => '完了',
            self::FAILED => '失敗'
        };
    }
}

// 従来の使用法
$oldStatus = OldPaymentStatus::PENDING;
if ($oldStatus === OldPaymentStatus::PENDING) {
    // 文字列同士の比較(型の保証なし)
}

// Enumの使用法
$status = PaymentStatus::PENDING;
if ($status === PaymentStatus::PENDING) {
    // オブジェクト同士の比較(型の保証あり)
}

PHP Enumの主なメリット

  1. 真の型安全性
    • 引数や戻り値に具体的なEnum型を指定可能
    • 不正な値の代入や比較を完全に防止
    • PHPStanやPsalmなどの静的解析ツールとの親和性向上
  2. IDEサポートの充実
    • コード補完で利用可能なすべてのケースを表示
    • リファクタリング時の一括変更が容易
    • 使用箇所の追跡が簡単
  3. ドメインロジックのカプセル化
    • Enumにメソッドを追加してロジックを集約
    • 表示用の文字列や振る舞いをEnum内に閉じ込め
    • 関連するロジックの散在を防止
  4. コードの明確さと自己文書化
    • パラメータの型からどのような値が期待されるか明確
    • 意図が明確になり、コメントの必要性が低減
    • コードの可読性と保守性が向上
  5. バリデーションの簡素化
    • from/tryFromメソッドによる入力値の検証
    • 不正値の早期検出が容易
    • 条件分岐の漏れを防止

PHP Enumの制限事項と注意点

  1. PHP 8.1以上の要件
    • 古いPHPバージョンをサポートする必要があるプロジェクトでは使用不可
    • 段階的移行の難しさ
  2. シリアライズの制限
    • Enumオブジェクトは直接シリアライズできない
    • データベース保存時に値への変換が必要
    • セッションへの保存に工夫が必要
  3. パフォーマンスへの影響
    • 単純な文字列定数と比較すると若干のオーバーヘッド
    • 大量のEnum操作が必要な場面では考慮が必要
  4. リフレクションの制限
    • 動的なEnum生成ができない
    • コンストラクタやプロパティに制約あり
  5. 既存コードからの移行コスト
    • 広範囲に使用されている定数の移行には労力が必要
    • データベースやAPIとの連携部分の修正が必要

Enumのメリットは制限事項を大きく上回ることが多く、新規プロジェクトやPHP 8.1以上を使用するプロジェクトでは積極的に採用すべき機能です。既存プロジェクトでは、重要度の高いドメインモデルから段階的に移行することで、効果的にEnumの恩恵を受けられるでしょう。

型安全性の向上とIDEサポートの充実

PHP Enumの最も重要なメリットの一つは、強力な型安全性の向上とそれに伴うIDEサポートの充実です。この両者が相まって、開発体験が劇的に向上します。

型安全性の向上

Enumを使用することで、従来の文字列や整数による定数表現と比較して、型レベルでの安全性が大幅に向上します:

// 従来の方式(型安全性なし)
function processOrder(string $status) {
    // $statusには任意の文字列が入る可能性あり
    if ($status === OrderStatus::SHIPPED) {
        // ...
    }
}

// Enumを使用(型安全性あり)
function processOrder(OrderStatus $status) {
    // $statusにはOrderStatusのインスタンスしか入らない
    if ($status === OrderStatus::SHIPPED) {
        // ...
    }
}

Enumを使用する主な型安全性のメリット:

  1. 不正な値の混入防止
    • 関数の引数として渡せるのはEnum型のインスタンスのみ
    • タイプミスやランタイムエラーのリスクが大幅に低減
  2. 網羅的な条件分岐の保証
    • matchやswitch文で全てのケースを網羅しているか検証可能
    • 新しいケースが追加された際の変更漏れを検出
  3. 値の変換と検証の自動化
    • from/tryFromメソッドによる安全な変換
    • 不正な値のエラー処理が簡略化

IDEサポートの充実

PhpStorm、VSCode + Intelephense、NetBeansなどの主要IDEは、PHP 8.1のEnum型を完全にサポートしており、以下のような開発体験の向上が見られます:

  1. インテリジェントなコード補完
    • Enumケースの名前を正確に補完
    • メソッドやプロパティの提案
    • ドキュメントのインライン表示
  2. リファクタリングのサポート
    • Enumケース名の一括変更
    • 使用箇所の自動検出と更新
    • 未使用のケースの警告
  3. コードナビゲーションの向上
    • ケース定義へのジャンプ
    • 使用箇所の一覧表示
    • 継承関係や実装の可視化
  4. リアルタイムエラー検出
    • 存在しないケースの参照を即座に警告
    • 型の不一致を視覚的に表示
    • Null安全性の検証

以下は、PhpStormでのEnum使用時のIDE機能の例です:

// Enum定義
enum PaymentMethod: string {
    case CREDIT_CARD = 'credit_card';
    case PAYPAL = 'paypal';
    case BANK_TRANSFER = 'bank_transfer';
    
    public function getDisplayName(): string {
        return match($this) {
            self::CREDIT_CARD => 'クレジットカード',
            self::PAYPAL => 'PayPal',
            self::BANK_TRANSFER => '銀行振込'
        };
    }
    
    public function getIcon(): string {
        return match($this) {
            self::CREDIT_CARD => 'credit-card-icon',
            self::PAYPAL => 'paypal-icon',
            self::BANK_TRANSFER => 'bank-icon'
        };
    }
}

// IDE補完とエラー検出の例
function processPayment(Order $order, PaymentMethod $method) {
    // $method. と入力すると、getDisplayName()とgetIcon()が補完候補として表示される
    $displayName = $method->getDisplayName();
    
    // PaymentMethod::CR と入力すると CREDIT_CARD が補完される
    if ($method === PaymentMethod::CREDIT_CARD) {
        // クレジットカード特有の処理
    }
    
    // 存在しないケースを参照するとIDEが即座に警告
    // if ($method === PaymentMethod::BITCOIN) { // エラー表示
    
    // match式で全てのケースを網羅していないとIDEが警告
    $fee = match($method) {
        PaymentMethod::CREDIT_CARD => 3.5,
        PaymentMethod::PAYPAL => 2.9,
        // BANK_TRANSFERが漏れているため警告
    };
}

静的解析ツールとの連携

PHP Enumは、PHPStanやPsalmなどの静的解析ツールとも強力に連携します:

  1. PHPStan Level 8+ での厳格な型チェック
    • Enumの網羅性チェック
    • 不可能な型の組み合わせの検出
    • 未定義ケースの参照検出
  2. Psalm での拡張バリデーション
    • Enumの使用パターンの検証
    • 潜在的な型の問題の発見
    • ドキュメント生成の強化

PHP Enumによる型安全性の向上とIDEサポートの充実は、単なる開発体験の向上だけでなく、バグの早期発見と予防、チーム開発の効率化、コードの自己文書化にも大きく貢献します。特に大規模なプロジェクトや複数人での開発において、その恩恵は顕著です。

Enumを使用する際の制限事項と注意点

PHP Enumは強力な機能ですが、いくつかの重要な制限事項があります。実際の開発で問題に直面する前に、これらの制約を理解し、適切な対応策を知っておくことが重要です。

1. シリアライズの制限

PHP Enumの最も大きな制限の一つは、シリアライズができないことです:

// シリアライズ試行
$status = OrderStatus::PENDING;
$serialized = serialize($status); // エラー: Uncaught Error: Serialization of backed enum is not allowed

この制限は、Enumの一貫性と不変性を保証するためのもので、以下のような場面で問題になります:

  • セッションへのEnum保存
  • キャッシュ(Redis/Memcachedなど)への保存
  • ジョブキューへの受け渡し

対応策:

// Backed Enumの場合:値を保存
function storeEnum(BackedEnum $enum): string|int {
    return $enum->value;
}

function retrieveEnum(string|int $value, string $enumClass): ?object {
    return $enumClass::tryFrom($value);
}

// 使用例
$storedValue = storeEnum(OrderStatus::PENDING); // 'pending'を保存
$enum = retrieveEnum($storedValue, OrderStatus::class); // OrderStatus::PENDINGを復元

// Pure Enumの場合:名前を保存
function storePureEnum(\UnitEnum $enum): string {
    return $enum->name;
}

function retrievePureEnum(string $name, string $enumClass): ?object {
    foreach ($enumClass::cases() as $case) {
        if ($case->name === $name) {
            return $case;
        }
    }
    return null;
}

2. プロパティとコンストラクタの制限

Enumはプロパティの定義とコンストラクタの使用に制限があります:

  • インスタンスプロパティを定義できない
  • コンストラクタのアクセス修飾子を変更できない
  • 独自のコンストラクタ引数を追加できない
enum Color
{
    case RED;
    case GREEN;
    case BLUE;
    
    // 以下はエラー: プライベートプロパティ
    // private string $hex;
    
    // 以下はエラー: コンストラクタのアクセス修飾子変更
    // public function __construct() { ... }
}

対応策:

enum Color
{
    case RED;
    case GREEN;
    case BLUE;
    
    // 静的プロパティは許可される
    private static array $hexValues = [
        self::RED->name => '#FF0000',
        self::GREEN->name => '#00FF00',
        self::BLUE->name => '#0000FF'
    ];
    
    // プロパティの代わりにメソッドを使用
    public function getHexValue(): string
    {
        return self::$hexValues[$this->name];
    }
}

3. 継承と拡張の制限

Enumは継承やクラス拡張に関して重要な制限があります:

  • Enumは他のクラスやEnumを継承できない
  • Enumは継承されることができない
  • 抽象Enumは作成できない
// 以下はすべて不可能
// enum AdvancedColor extends Color { ... }
// enum DetailedStatus extends AbstractStatus { ... }
// abstract enum AbstractStatus { ... }

対応策:

// 継承の代わりにインターフェースと複合を使用
interface ColorInterface
{
    public function getHexValue(): string;
    public function getRgbValue(): array;
}

enum Color implements ColorInterface
{
    case RED;
    case GREEN;
    case BLUE;
    
    public function getHexValue(): string
    {
        return match($this) {
            self::RED => '#FF0000',
            self::GREEN => '#00FF00',
            self::BLUE => '#0000FF',
        };
    }
    
    public function getRgbValue(): array
    {
        return match($this) {
            self::RED => [255, 0, 0],
            self::GREEN => [0, 255, 0],
            self::BLUE => [0, 0, 255],
        };
    }
}

4. データベースとの連携制限

Enumをデータベースと連携する際には、いくつかの注意点があります:

  • Backed Enumの値をカラムに保存する必要がある
  • ORMツールによってEnum対応状況が異なる
  • マイグレーションとスキーマの適切な設定が必要

対応策:

// Eloquent (Laravel) の例
class Order extends Model
{
    protected $casts = [
        'status' => OrderStatus::class,
    ];
}

// Doctrine の例
/**
 * @Entity
 */
class Order
{
    /**
     * @Column(type="string", enumType=OrderStatus::class)
     */
    private OrderStatus $status;
}

5. パフォーマンスへの影響

Enumは単純な定数よりもオーバーヘッドがあります:

  • オブジェクトとしてのメモリ使用量
  • メソッド呼び出しのコスト
  • 大量のEnum操作時のパフォーマンス影響

非常に高頻度の操作や極めてパフォーマンスクリティカルな部分では、この点を考慮する必要があります。

6. PHP 8.1要件の互換性問題

Enumを使用するにはPHP 8.1以上が必要なため、以下のような互換性の問題があります:

  • レガシーシステムとの統合が難しい
  • ホスティング環境の制約
  • ライブラリの互換性要件

対応策:

// ポリフィル的なアプローチ(完全な代替ではない)
if (PHP_VERSION_ID < 80100) {
    class OrderStatus
    {
        public const PENDING = 'pending';
        public const PROCESSING = 'processing';
        public const COMPLETED = 'completed';
        
        private function __construct() {}
        
        public static function from(string $value): self
        {
            if (!in_array($value, [self::PENDING, self::PROCESSING, self::COMPLETED])) {
                throw new \ValueError("Invalid value");
            }
            
            $instance = new self();
            $instance->value = $value;
            return $instance;
        }
    }
} else {
    enum OrderStatus: string
    {
        case PENDING = 'pending';
        case PROCESSING = 'processing';
        case COMPLETED = 'completed';
    }
}

これらの制限事項を理解した上で、適切な場面でEnumを活用することが重要です。多くの場合、これらの制限は回避可能であり、Enumのメリットがこれらの制約を十分に上回ることが多いでしょう。

PHP Enumの実践的活用法:5つの実装パターン

PHP Enumの基本的な構文と特性を理解したところで、実際のプロジェクトでどのように活用できるかを見ていきましょう。ここでは、PHPアプリケーションにおけるEnumの5つの実践的な実装パターンを紹介します。

パターン概要

PHP Enumは様々な場面で活用できますが、特に以下の5つのパターンが効果的です:

  1. ステータス管理とフロー制御
    • ビジネスプロセスの状態遷移をEnum型で表現
    • ワークフローやステートマシンの実装
    • ライフサイクル管理と状態遷移の制約
  2. バリデーションとデータ整合性の確保
    • 入力値の検証と型強制
    • ドメイン制約とビジネスルールの実装
    • データベースとの安全な連携
  3. ポリシーとパーミッション管理
    • 権限とロールの体系的な管理
    • 条件分岐の簡素化と可読性向上
    • アクセス制御の一元管理
  4. 値オブジェクトとドメイン駆動設計
    • ドメインモデルの忠実な表現
    • 値オブジェクトとしてのEnum活用
    • ビジネスルールのカプセル化
  5. APIレスポンスとエラーハンドリング
    • 一貫性のあるAPI応答の構築
    • エラーコードと例外の体系的管理
    • 多言語対応とメッセージの国際化

実装パターンの選択基準

これらのパターンはアプリケーションの特性や要件に応じて選択・組み合わせが可能です:

パターン最適な使用場面主な利点
ステータス管理複雑なワークフローがあるアプリケーションプロセスの可視化、不正な状態遷移の防止
バリデーションユーザー入力や外部データを扱うシステム一貫したデータ検証、ドメイン制約の明確化
パーミッション複数権限レベルを持つシステムアクセス制御の簡素化、権限設計の柔軟性
値オブジェクトドメイン駆動設計採用プロジェクト豊かなドメインモデル、ビジネスルールの集約
APIレスポンスマイクロサービスやWeb API一貫したレスポンス形式、国際化対応

実装パターンの組み合わせ

実際のプロジェクトでは、これらのパターンを組み合わせて使用することが一般的です。例えば:

  • Eコマースサイト:ステータス管理 + バリデーション + 値オブジェクト
  • CRMシステム:ポリシー管理 + ステータス管理 + APIレスポンス
  • 金融アプリケーション:バリデーション + 値オブジェクト + エラーハンドリング

これらのパターンは独立して使えるだけでなく、組み合わせることでさらに強力になります。各パターンの詳細と具体的な実装例を以降のセクションで詳しく見ていきましょう。

各実装パターンには、実際のプロジェクトでそのまま使える実用的なコード例と、実装時の注意点を含めて解説します。これらのパターンを活用することで、PHPアプリケーションの品質と保守性を大きく向上させることができるでしょう。

実践例1:ステータス管理とフロー制御

多くのビジネスアプリケーションでは、オブジェクトが異なる状態間を遷移するプロセスを管理する必要があります。Eコマースの注文処理、コンテンツ承認ワークフロー、タスク管理など、状態遷移は様々な場面で登場します。PHP Enumはこれらのステータス管理とフロー制御を格段に改善します。

ステータス管理の従来の課題

従来の文字列ベースのステータス管理には以下のような問題がありました:

  • 状態の不一致(タイプミスや無効な値)
  • 状態遷移ルールの分散とメンテナンス困難
  • 状態に関連する振る舞いの散在
  • 不正な遷移の検出の難しさ

Enumによるステータス管理のメリット

Enumを使用することで、これらの問題を解決できます:

  • 型安全性による無効な状態の排除
  • 状態遷移ルールの集約
  • 関連する振る舞いのカプセル化
  • コードの可読性と保守性の向上

実装例:注文ステータス管理

以下は、Eコマースの注文処理でEnumを使用したステータス管理の実装例です:

enum OrderStatus
{
    case CART;
    case CHECKOUT;
    case PAYMENT_PENDING;
    case PAYMENT_RECEIVED;
    case PROCESSING;
    case SHIPPED;
    case DELIVERED;
    case CANCELLED;
    case REFUNDED;

    /**
     * 次の有効なステータスを取得
     */
    public function nextPossibleStatuses(): array
    {
        return match($this) {
            self::CART => [self::CHECKOUT, self::CANCELLED],
            self::CHECKOUT => [self::PAYMENT_PENDING, self::CANCELLED],
            self::PAYMENT_PENDING => [self::PAYMENT_RECEIVED, self::CANCELLED],
            self::PAYMENT_RECEIVED => [self::PROCESSING, self::REFUNDED],
            self::PROCESSING => [self::SHIPPED, self::REFUNDED],
            self::SHIPPED => [self::DELIVERED, self::REFUNDED],
            self::DELIVERED => [self::REFUNDED],
            self::CANCELLED, self::REFUNDED => [],
        };
    }

    /**
     * 特定のステータスへの遷移が可能かをチェック
     */
    public function canTransitionTo(self $newStatus): bool
    {
        return in_array($newStatus, $this->nextPossibleStatuses());
    }

    /**
     * ステータスに関連するユーザー表示用のラベルを取得
     */
    public function getLabel(): string
    {
        return match($this) {
            self::CART => 'カート',
            self::CHECKOUT => 'チェックアウト',
            self::PAYMENT_PENDING => '支払い待ち',
            self::PAYMENT_RECEIVED => '入金確認済み',
            self::PROCESSING => '処理中',
            self::SHIPPED => '発送済み',
            self::DELIVERED => '配達完了',
            self::CANCELLED => 'キャンセル',
            self::REFUNDED => '返金済み',
        };
    }

    /**
     * ステータスが最終状態かどうかを確認
     */
    public function isFinal(): bool
    {
        return in_array($this, [self::DELIVERED, self::CANCELLED, self::REFUNDED]);
    }

    /**
     * ステータスが有効なアクションかどうかを確認
     */
    public function canPerformAction(string $action): bool
    {
        return match($this) {
            self::CART => in_array($action, ['update', 'checkout', 'cancel']),
            self::CHECKOUT => in_array($action, ['pay', 'cancel']),
            self::PAYMENT_PENDING => in_array($action, ['confirm_payment', 'cancel']),
            self::PAYMENT_RECEIVED => in_array($action, ['process', 'refund']),
            self::PROCESSING => in_array($action, ['ship', 'refund']),
            self::SHIPPED => in_array($action, ['confirm_delivery', 'refund']),
            self::DELIVERED => in_array($action, ['refund']),
            self::CANCELLED, self::REFUNDED => false,
        };
    }
}

実際の使用例

class Order
{
    private OrderStatus $status;

    public function __construct()
    {
        $this->status = OrderStatus::CART;
    }

    public function getStatus(): OrderStatus
    {
        return $this->status;
    }

    public function transitionTo(OrderStatus $newStatus): void
    {
        if (!$this->status->canTransitionTo($newStatus)) {
            throw new InvalidStatusTransitionException(
                "Cannot transition from {$this->status->name} to {$newStatus->name}"
            );
        }

        $this->status = $newStatus;
        $this->logStatusChange($newStatus);
    }

    private function logStatusChange(OrderStatus $newStatus): void
    {
        // ステータス変更のログを記録
    }

    public function performAction(string $action): void
    {
        if (!$this->status->canPerformAction($action)) {
            throw new InvalidActionException(
                "Cannot perform {$action} in status {$this->status->name}"
            );
        }

        // アクションの実行ロジック
        match($action) {
            'checkout' => $this->transitionTo(OrderStatus::CHECKOUT),
            'pay' => $this->transitionTo(OrderStatus::PAYMENT_PENDING),
            'confirm_payment' => $this->transitionTo(OrderStatus::PAYMENT_RECEIVED),
            // 他のアクション
            default => throw new UnknownActionException("Unknown action: {$action}")
        };
    }
}

// コントローラーでの使用例
public function processOrder(int $orderId, string $action)
{
    $order = $this->orderRepository->find($orderId);

    try {
        $order->performAction($action);
        $this->orderRepository->save($order);
        return ['success' => true];
    } catch (InvalidActionException $e) {
        return ['error' => $e->getMessage()];
    }
}

このパターンは多くの利点を提供します:

  1. 型安全性 – 無効なステータスが混入する可能性を排除
  2. ビジネスルールの集約 – 遷移ルールとステータスロジックが一箇所に集約
  3. 自己文書化コード – コードだけでフロー全体が明確に理解できる
  4. テスト容易性 – ステータス遷移を独立してテスト可能
  5. 拡張性 – 新しいステータスや遷移ルールの追加が容易

ステートマシンの構築

より複雑なフロー制御が必要な場合は、Enumをベースとした本格的なステートマシンを構築できます。以下は、そのアプローチです:

// ステートマシンインターフェース
interface StateMachineInterface
{
    public function getCurrentState(): UnitEnum;
    public function canTransitionTo(UnitEnum $state): bool;
    public function transitionTo(UnitEnum $state): void;
}

// 汎用ステートマシン実装
class StateMachine implements StateMachineInterface
{
    private UnitEnum $currentState;
    private array $transitionCallbacks = [];

    public function __construct(UnitEnum $initialState)
    {
        $this->currentState = $initialState;
    }

    public function getCurrentState(): UnitEnum
    {
        return $this->currentState;
    }

    public function canTransitionTo(UnitEnum $state): bool
    {
        // ステート固有のcanTransitionToメソッドがあれば使用
        if (method_exists($this->currentState, 'canTransitionTo')) {
            return $this->currentState->canTransitionTo($state);
        }

        // または明示的な遷移ルールを定義
        return true; // デフォルトでは全ての遷移を許可
    }

    public function transitionTo(UnitEnum $state): void
    {
        if (!$this->canTransitionTo($state)) {
            throw new InvalidTransitionException(
                "Cannot transition from {$this->currentState->name} to {$state->name}"
            );
        }

        $oldState = $this->currentState;
        $this->currentState = $state;

        // 遷移コールバックを実行
        if (isset($this->transitionCallbacks[$oldState->name][$state->name])) {
            $callback = $this->transitionCallbacks[$oldState->name][$state->name];
            $callback($oldState, $state);
        }
    }

    public function onTransition(UnitEnum $from, UnitEnum $to, callable $callback): void
    {
        $this->transitionCallbacks[$from->name][$to->name] = $callback;
    }
}

// ドキュメント承認ワークフローの例
enum DocumentStatus
{
    case DRAFT;
    case UNDER_REVIEW;
    case APPROVED;
    case REJECTED;
    case PUBLISHED;

    public function canTransitionTo(self $newStatus): bool
    {
        return match($this) {
            self::DRAFT => in_array($newStatus, [self::UNDER_REVIEW]),
            self::UNDER_REVIEW => in_array($newStatus, [self::APPROVED, self::REJECTED]),
            self::APPROVED => in_array($newStatus, [self::PUBLISHED, self::UNDER_REVIEW]),
            self::REJECTED => in_array($newStatus, [self::DRAFT]),
            self::PUBLISHED => false,
        };
    }
}

// 使用例
class Document
{
    private StateMachine $stateMachine;
    private string $content;

    public function __construct()
    {
        $this->stateMachine = new StateMachine(DocumentStatus::DRAFT);

        // 状態遷移時のコールバックを設定
        $this->stateMachine->onTransition(
            DocumentStatus::APPROVED, 
            DocumentStatus::PUBLISHED,
            function($from, $to) {
                $this->notifySubscribers();
            }
        );
    }

    public function getStatus(): DocumentStatus
    {
        return $this->stateMachine->getCurrentState();
    }

    public function submitForReview(): void
    {
        $this->stateMachine->transitionTo(DocumentStatus::UNDER_REVIEW);
    }

    public function approve(): void
    {
        $this->stateMachine->transitionTo(DocumentStatus::APPROVED);
    }

    public function reject(): void
    {
        $this->stateMachine->transitionTo(DocumentStatus::REJECTED);
    }

    public function publish(): void
    {
        $this->stateMachine->transitionTo(DocumentStatus::PUBLISHED);
    }

    private function notifySubscribers(): void
    {
        // 購読者に通知を送信
    }
}

実装上の注意点とベストプラクティス

  1. 状態と振る舞いを一箇所に集約する – ステータスに関連するロジックはEnum内に含める
  2. 遷移ルールを明示的に定義する – 許可される遷移を明確にドキュメント化
  3. ガード条件を追加する – 単なるステータスだけでなく、コンテキスト依存の遷移条件も考慮
  4. 副作用を分離する – ステータス変更時のサイドエフェクトは別途管理(イベントリスナーなど)
  5. 履歴を記録する – ステータス変更の履歴を保持して監査やデバッグに活用

ステータス管理とフロー制御のパターンは、プロセス指向のアプリケーションで特に強力です。次のセクションでは、具体的な注文ステータスの実装例と共に、より詳細な活用法を見ていきましょう。

注文ステータスをEnumで表現する実装例

EC(電子商取引)アプリケーションで最も重要なビジネスプロセスの一つが注文処理です。注文は様々なステータスを経て処理されるため、これをEnum型で効果的に表現できます。ここでは、実際のプロジェクトですぐに使える完全な実装例を紹介します。

包括的な注文ステータスEnum

以下は、一般的なECサイトの注文処理フローをカバーする包括的なEnum実装です:

enum OrderStatus: string
{
    // 注文前のステータス
    case CART = 'cart';                     // カート内
    case CHECKOUT_STARTED = 'checkout';     // 決済プロセス開始
    
    // 支払い関連ステータス
    case AWAITING_PAYMENT = 'awaiting_payment';      // 支払い待ち
    case PAYMENT_PROCESSING = 'payment_processing';  // 支払い処理中
    case PAYMENT_FAILED = 'payment_failed';          // 支払い失敗
    case PAYMENT_VERIFIED = 'payment_verified';      // 支払い確認済み
    case PAID = 'paid';                              // 入金完了
    
    // 処理ステータス
    case PROCESSING = 'processing';         // 注文処理中
    case ON_HOLD = 'on_hold';               // 保留中(在庫確認等)
    case READY_TO_SHIP = 'ready_to_ship';   // 出荷準備完了
    
    // 配送ステータス
    case PARTIALLY_SHIPPED = 'partially_shipped';  // 一部出荷済み
    case SHIPPED = 'shipped';                      // 出荷済み
    case OUT_FOR_DELIVERY = 'out_for_delivery';    // 配達中
    case DELIVERED = 'delivered';                  // 配達完了
    
    // 完了・キャンセルステータス
    case COMPLETED = 'completed';           // 完了(配達確認済み)
    case CANCELLED = 'cancelled';           // キャンセル
    case REFUNDED = 'refunded';             // 返金済み
    case PARTIALLY_REFUNDED = 'partially_refunded'; // 一部返金
    
    // ステータス情報を取得
    public function getInfo(): array
    {
        return match($this) {
            self::CART => [
                'label' => 'カート',
                'description' => '商品がカートに追加されています',
                'color' => 'gray',
                'public' => false, // お客様には表示しない内部ステータス
                'admin_action' => null,
            ],
            self::CHECKOUT_STARTED => [
                'label' => 'チェックアウト中',
                'description' => '決済処理が開始されました',
                'color' => 'gray',
                'public' => false,
                'admin_action' => null,
            ],
            self::AWAITING_PAYMENT => [
                'label' => '支払い待ち',
                'description' => 'お支払いをお待ちしています',
                'color' => 'yellow',
                'public' => true,
                'admin_action' => 'キャンセル可能',
            ],
            self::PAYMENT_PROCESSING => [
                'label' => '決済処理中',
                'description' => '決済情報を処理中です',
                'color' => 'yellow',
                'public' => true,
                'admin_action' => '手動確認可能',
            ],
            self::PAYMENT_FAILED => [
                'label' => '支払い失敗',
                'description' => '決済処理に失敗しました',
                'color' => 'red',
                'public' => true,
                'admin_action' => '再試行案内',
            ],
            self::PAYMENT_VERIFIED => [
                'label' => '支払い確認済み',
                'description' => 'お支払いが確認されました',
                'color' => 'green',
                'public' => true,
                'admin_action' => '処理開始可能',
            ],
            self::PAID => [
                'label' => '入金完了',
                'description' => '入金が完了しました',
                'color' => 'green',
                'public' => true,
                'admin_action' => '処理開始可能',
            ],
            self::PROCESSING => [
                'label' => '処理中',
                'description' => 'ご注文を処理中です',
                'color' => 'blue',
                'public' => true,
                'admin_action' => '出荷指示可能',
            ],
            self::ON_HOLD => [
                'label' => '保留中',
                'description' => '確認作業のため保留中です',
                'color' => 'orange',
                'public' => true,
                'admin_action' => '確認必要',
            ],
            self::READY_TO_SHIP => [
                'label' => '出荷準備完了',
                'description' => '出荷の準備が整いました',
                'color' => 'blue',
                'public' => true,
                'admin_action' => '出荷処理',
            ],
            self::PARTIALLY_SHIPPED => [
                'label' => '一部出荷済み',
                'description' => '商品の一部が出荷されました',
                'color' => 'blue',
                'public' => true,
                'admin_action' => '残り出荷処理',
            ],
            self::SHIPPED => [
                'label' => '出荷済み',
                'description' => '商品が発送されました',
                'color' => 'blue',
                'public' => true,
                'admin_action' => '配送状況確認',
            ],
            self::OUT_FOR_DELIVERY => [
                'label' => '配達中',
                'description' => '配達中です',
                'color' => 'purple',
                'public' => true,
                'admin_action' => '配送状況確認',
            ],
            self::DELIVERED => [
                'label' => '配達完了',
                'description' => '商品が配達されました',
                'color' => 'green',
                'public' => true,
                'admin_action' => '完了確認',
            ],
            self::COMPLETED => [
                'label' => '完了',
                'description' => '注文が完了しました',
                'color' => 'green',
                'public' => true,
                'admin_action' => null,
            ],
            self::CANCELLED => [
                'label' => 'キャンセル',
                'description' => '注文がキャンセルされました',
                'color' => 'red',
                'public' => true,
                'admin_action' => '返金処理(必要時)',
            ],
            self::REFUNDED => [
                'label' => '返金済み',
                'description' => '全額返金処理が完了しました',
                'color' => 'red',
                'public' => true,
                'admin_action' => null,
            ],
            self::PARTIALLY_REFUNDED => [
                'label' => '一部返金済み',
                'description' => '一部の返金処理が完了しました',
                'color' => 'orange',
                'public' => true,
                'admin_action' => '残額返金(必要時)',
            ],
        };
    }
    
    // ラベルのみを取得するショートカット
    public function getLabel(): string
    {
        return $this->getInfo()['label'];
    }
    
    // 説明を取得するショートカット
    public function getDescription(): string
    {
        return $this->getInfo()['description'];
    }
    
    // ステータスの色コードを取得
    public function getColor(): string
    {
        return $this->getInfo()['color'];
    }
    
    // 顧客に表示して良いステータスかどうか
    public function isPublic(): bool
    {
        return $this->getInfo()['public'];
    }
    
    // 次に可能なステータスの配列を取得
    public function getNextPossibleStatuses(): array
    {
        return match($this) {
            self::CART => [self::CHECKOUT_STARTED],
            self::CHECKOUT_STARTED => [self::AWAITING_PAYMENT, self::PAYMENT_PROCESSING, self::CART],
            self::AWAITING_PAYMENT => [self::PAYMENT_PROCESSING, self::PAYMENT_FAILED, self::PAYMENT_VERIFIED, self::CANCELLED],
            self::PAYMENT_PROCESSING => [self::PAYMENT_FAILED, self::PAYMENT_VERIFIED, self::CANCELLED],
            self::PAYMENT_FAILED => [self::AWAITING_PAYMENT, self::PAYMENT_PROCESSING, self::CANCELLED],
            self::PAYMENT_VERIFIED, self::PAID => [self::PROCESSING, self::ON_HOLD, self::CANCELLED],
            self::PROCESSING => [self::ON_HOLD, self::READY_TO_SHIP, self::CANCELLED],
            self::ON_HOLD => [self::PROCESSING, self::READY_TO_SHIP, self::CANCELLED],
            self::READY_TO_SHIP => [self::SHIPPED, self::PARTIALLY_SHIPPED, self::CANCELLED],
            self::PARTIALLY_SHIPPED => [self::SHIPPED, self::CANCELLED],
            self::SHIPPED => [self::OUT_FOR_DELIVERY, self::DELIVERED, self::CANCELLED],
            self::OUT_FOR_DELIVERY => [self::DELIVERED, self::CANCELLED],
            self::DELIVERED => [self::COMPLETED, self::REFUNDED, self::PARTIALLY_REFUNDED],
            self::COMPLETED => [self::REFUNDED, self::PARTIALLY_REFUNDED],
            self::CANCELLED => [self::REFUNDED],
            self::REFUNDED, self::PARTIALLY_REFUNDED => [],
        };
    }
    
    // 特定のステータスへ遷移可能かを確認
    public function canTransitionTo(self $newStatus): bool
    {
        return in_array($newStatus, $this->getNextPossibleStatuses());
    }
    
    // ステータスが最終状態かどうかを確認
    public function isFinal(): bool
    {
        return in_array($this, [
            self::COMPLETED,
            self::CANCELLED,
            self::REFUNDED,
            self::PARTIALLY_REFUNDED
        ]);
    }
    
    // 支払い関連のステータスかどうか
    public function isPaymentRelated(): bool
    {
        return in_array($this, [
            self::AWAITING_PAYMENT,
            self::PAYMENT_PROCESSING,
            self::PAYMENT_FAILED,
            self::PAYMENT_VERIFIED,
            self::PAID
        ]);
    }
    
    // 配送関連のステータスかどうか
    public function isShippingRelated(): bool
    {
        return in_array($this, [
            self::READY_TO_SHIP,
            self::PARTIALLY_SHIPPED,
            self::SHIPPED,
            self::OUT_FOR_DELIVERY,
            self::DELIVERED
        ]);
    }
}

Laravelでの実装例

Laravelでこの注文ステータスを使用する例:

// Order.php モデル
class Order extends Model
{
    protected $casts = [
        'status' => OrderStatus::class,
    ];
    
    // ステータス変更メソッド
    public function updateStatus(OrderStatus $newStatus): void
    {
        if (!$this->status->canTransitionTo($newStatus)) {
            throw new OrderStatusTransitionException(
                "Cannot transition from {$this->status->name} to {$newStatus->name}"
            );
        }
        
        $oldStatus = $this->status;
        $this->status = $newStatus;
        $this->save();
        
        // ステータス変更イベントを発火
        event(new OrderStatusChanged($this, $oldStatus, $newStatus));
    }
    
    // ステータス履歴の取得
    public function statusHistory()
    {
        return $this->hasMany(OrderStatusHistory::class);
    }
}

// マイグレーションファイル
public function up()
{
    Schema::create('orders', function (Blueprint $table) {
        $table->id();
        $table->string('status')->default(OrderStatus::CART->value);
        // その他のカラム
        $table->timestamps();
    });
    
    Schema::create('order_status_history', function (Blueprint $table) {
        $table->id();
        $table->foreignId('order_id')->constrained();
        $table->string('from_status');
        $table->string('to_status');
        $table->string('comment')->nullable();
        $table->foreignId('user_id')->nullable()->constrained();
        $table->timestamps();
    });
}

コントローラーでの使用例

class OrderController extends Controller
{
    public function processOrder(Order $order)
    {
        // 権限チェック
        $this->authorize('process', $order);
        
        try {
            $order->updateStatus(OrderStatus::PROCESSING);
            
            return redirect()->back()->with(
                'success', 
                '注文を' . $order->status->getLabel() . 'に更新しました'
            );
        } catch (OrderStatusTransitionException $e) {
            return redirect()->back()->with('error', $e->getMessage());
        }
    }
    
    public function getOrderHistory(Order $order)
    {
        $history = $order->statusHistory()->latest()->get()->map(function ($item) {
            return [
                'date' => $item->created_at->format('Y-m-d H:i'),
                'from' => OrderStatus::from($item->from_status)->getLabel(),
                'to' => OrderStatus::from($item->to_status)->getLabel(),
                'by' => $item->user ? $item->user->name : 'システム',
                'comment' => $item->comment,
            ];
        });
        
        return view('orders.history', compact('order', 'history'));
    }
}

イベントリスナーでの活用例

class OrderStatusChangeListener
{
    public function handle(OrderStatusChanged $event)
    {
        $order = $event->order;
        $oldStatus = $event->oldStatus;
        $newStatus = $event->newStatus;
        
        // ステータス履歴の記録
        $order->statusHistory()->create([
            'from_status' => $oldStatus->value,
            'to_status' => $newStatus->value,
            'user_id' => auth()->id(),
        ]);
        
        // メール通知の送信(特定のステータス変更時のみ)
        if ($newStatus->isPublic()) {
            Mail::to($order->customer->email)->send(
                new OrderStatusUpdated($order)
            );
        }
        
        // 在庫調整(特定のステータス変更時)
        if ($newStatus === OrderStatus::CANCELLED) {
            $this->restoreInventory($order);
        }
        
        // 出荷指示書の生成
        if ($newStatus === OrderStatus::READY_TO_SHIP) {
            $this->generateShippingInstructions($order);
        }
    }
}

この実装例は、実際のECサイトで必要となる注文ステータスの操作を網羅的にカバーしています。ビジネスロジックとステータス遷移ルールを一箇所に集約することで、コードの保守性と拡張性が大きく向上します。また、Enum型の力を活かして、コード全体の堅牢性と型安全性を確保しています。

Enumを使ったステートマシンの構築方法

ステートマシン(状態機械)は、オブジェクトがとりうる状態と、状態間の遷移を明示的にモデル化するデザインパターンです。PHPのEnum型を活用することで、型安全で堅牢なステートマシンを構築できます。

ステートマシンの基本概念

ステートマシンの核となる要素は以下の通りです:

  1. 状態(State) – システムがとりうる離散的な状態の集合
  2. 遷移(Transition) – ある状態から別の状態への移動
  3. イベント(Event) – 遷移をトリガーする出来事
  4. ガード条件(Guard) – 遷移が許可されるための条件
  5. アクション(Action) – 遷移時に実行される処理

Enumを使った軽量ステートマシン

シンプルなステートマシンは、Enumだけで実装できます:

enum TaskState
{
    case TODO;
    case IN_PROGRESS;
    case REVIEW;
    case DONE;
    
    // 次の状態への遷移ルール
    public function canTransitionTo(self $nextState): bool
    {
        return match($this) {
            self::TODO => $nextState === self::IN_PROGRESS,
            self::IN_PROGRESS => in_array($nextState, [self::REVIEW, self::TODO]),
            self::REVIEW => in_array($nextState, [self::DONE, self::IN_PROGRESS]),
            self::DONE => false, // 完了は最終状態
        };
    }
    
    // 遷移時のアクション
    public function onEnter(): string
    {
        return match($this) {
            self::TODO => 'タスクが作成されました',
            self::IN_PROGRESS => '作業が開始されました',
            self::REVIEW => 'レビュー依頼が出されました',
            self::DONE => 'タスクが完了しました',
        };
    }
}

// 使用例
class Task
{
    private TaskState $state;
    
    public function __construct()
    {
        $this->state = TaskState::TODO;
    }
    
    public function getState(): TaskState
    {
        return $this->state;
    }
    
    public function changeState(TaskState $newState): string
    {
        if (!$this->state->canTransitionTo($newState)) {
            throw new InvalidStateTransitionException(
                "Cannot transition from {$this->state->name} to {$newState->name}"
            );
        }
        
        $this->state = $newState;
        return $newState->onEnter();
    }
}

完全なステートマシン実装

より複雑なユースケースでは、柔軟で拡張可能なステートマシンの実装が必要です。以下は、Enum型を中心とした完全なステートマシンの実装例です:

// 1. ステートマシンインターフェース
interface StateMachineInterface
{
    public function getCurrentState(): UnitEnum;
    public function canTransitionTo(UnitEnum $state): bool;
    public function transitionTo(UnitEnum $state, array $context = []): void;
    public function addTransitionListener(callable $listener): void;
}

// 2. 遷移イベントクラス
class TransitionEvent
{
    public function __construct(
        public readonly UnitEnum $fromState,
        public readonly UnitEnum $toState,
        public readonly array $context = []
    ) {}
}

// 3. ステートマシンの実装
class StateMachine implements StateMachineInterface
{
    private UnitEnum $currentState;
    private array $transitionListeners = [];
    private array $guardConditions = [];
    
    public function __construct(UnitEnum $initialState)
    {
        $this->currentState = $initialState;
    }
    
    public function getCurrentState(): UnitEnum
    {
        return $this->currentState;
    }
    
    // カスタムガード条件を追加
    public function addGuardCondition(UnitEnum $fromState, UnitEnum $toState, callable $condition): void
    {
        $this->guardConditions[$fromState->name][$toState->name][] = $condition;
    }
    
    // 遷移リスナーを追加
    public function addTransitionListener(callable $listener): void
    {
        $this->transitionListeners[] = $listener;
    }
    
    // 遷移が可能かどうかをチェック
    public function canTransitionTo(UnitEnum $state): bool
    {
        // 1. Enum自体のcanTransitionToメソッドをチェック
        if (method_exists($this->currentState, 'canTransitionTo')) {
            if (!$this->currentState->canTransitionTo($state)) {
                return false;
            }
        }
        
        // 2. 登録されたガード条件をチェック
        if (isset($this->guardConditions[$this->currentState->name][$state->name])) {
            foreach ($this->guardConditions[$this->currentState->name][$state->name] as $condition) {
                if (!$condition($this->currentState, $state)) {
                    return false;
                }
            }
        }
        
        return true;
    }
    
    // 状態を遷移
    public function transitionTo(UnitEnum $state, array $context = []): void
    {
        if (!$this->canTransitionTo($state)) {
            throw new InvalidStateTransitionException(
                "Cannot transition from {$this->currentState->name} to {$state->name}"
            );
        }
        
        $oldState = $this->currentState;
        $this->currentState = $state;
        
        // 遷移イベントを作成
        $event = new TransitionEvent($oldState, $state, $context);
        
        // 登録されたリスナーを呼び出し
        foreach ($this->transitionListeners as $listener) {
            $listener($event);
        }
        
        // EnumにonExitやonEnterメソッドがあれば呼び出し
        if (method_exists($oldState, 'onExit')) {
            $oldState->onExit($state, $context);
        }
        
        if (method_exists($state, 'onEnter')) {
            $state->onEnter($oldState, $context);
        }
    }
}

実践的なユースケース:ドキュメント承認ワークフロー

このステートマシンの実践的な使用例として、ドキュメント承認ワークフローを実装してみましょう:

// ドキュメント状態の定義
enum DocumentState
{
    case DRAFT;
    case SUBMITTED;
    case UNDER_REVIEW;
    case CHANGES_REQUESTED;
    case APPROVED;
    case PUBLISHED;
    case ARCHIVED;
    
    // 許可される遷移を定義
    public function canTransitionTo(self $state): bool
    {
        return match($this) {
            self::DRAFT => in_array($state, [self::SUBMITTED]),
            self::SUBMITTED => in_array($state, [self::UNDER_REVIEW, self::DRAFT]),
            self::UNDER_REVIEW => in_array($state, [self::CHANGES_REQUESTED, self::APPROVED]),
            self::CHANGES_REQUESTED => in_array($state, [self::DRAFT, self::SUBMITTED]),
            self::APPROVED => in_array($state, [self::PUBLISHED, self::UNDER_REVIEW]),
            self::PUBLISHED => in_array($state, [self::ARCHIVED]),
            self::ARCHIVED => false,
        };
    }
    
    // 状態表示用のメタデータ
    public function getMetadata(): array
    {
        return match($this) {
            self::DRAFT => [
                'label' => '下書き',
                'color' => 'gray',
                'icon' => 'draft-icon',
            ],
            self::SUBMITTED => [
                'label' => '提出済み',
                'color' => 'blue',
                'icon' => 'submit-icon',
            ],
            self::UNDER_REVIEW => [
                'label' => 'レビュー中',
                'color' => 'orange',
                'icon' => 'review-icon',
            ],
            self::CHANGES_REQUESTED => [
                'label' => '修正依頼',
                'color' => 'red',
                'icon' => 'changes-icon',
            ],
            self::APPROVED => [
                'label' => '承認済み',
                'color' => 'green',
                'icon' => 'approved-icon',
            ],
            self::PUBLISHED => [
                'label' => '公開中',
                'color' => 'green',
                'icon' => 'published-icon',
            ],
            self::ARCHIVED => [
                'label' => 'アーカイブ済み',
                'color' => 'gray',
                'icon' => 'archive-icon',
            ],
        };
    }
}

// ドキュメントクラス
class Document
{
    private string $title;
    private string $content;
    private User $author;
    private StateMachine $stateMachine;
    private array $reviewComments = [];
    
    public function __construct(string $title, User $author)
    {
        $this->title = $title;
        $this->author = $author;
        
        // ステートマシンを初期化
        $this->stateMachine = new StateMachine(DocumentState::DRAFT);
        
        // 遷移リスナーを追加(ログ記録用)
        $this->stateMachine->addTransitionListener(function(TransitionEvent $event) {
            $this->logStateChange($event->fromState, $event->toState, $event->context);
        });
        
        // 特定の遷移にのみ適用されるガード条件を追加
        $this->stateMachine->addGuardCondition(
            DocumentState::SUBMITTED,
            DocumentState::UNDER_REVIEW,
            function($from, $to, $context = []) {
                // レビュアーが割り当てられているかチェック
                return isset($this->reviewers) && count($this->reviewers) > 0;
            }
        );
        
        // 公開時の通知用リスナー
        $this->stateMachine->addTransitionListener(function(TransitionEvent $event) {
            if ($event->toState === DocumentState::PUBLISHED) {
                $this->notifySubscribers();
            }
        });
    }
    
    // 状態を取得
    public function getState(): DocumentState
    {
        return $this->stateMachine->getCurrentState();
    }
    
    // ドキュメントを提出
    public function submit(): void
    {
        $this->stateMachine->transitionTo(DocumentState::SUBMITTED);
    }
    
    // レビュー開始
    public function startReview(): void
    {
        $this->stateMachine->transitionTo(DocumentState::UNDER_REVIEW);
    }
    
    // 修正依頼
    public function requestChanges(string $comment): void
    {
        $this->reviewComments[] = $comment;
        $this->stateMachine->transitionTo(
            DocumentState::CHANGES_REQUESTED,
            ['comment' => $comment]
        );
    }
    
    // 承認
    public function approve(): void
    {
        $this->stateMachine->transitionTo(DocumentState::APPROVED);
    }
    
    // 公開
    public function publish(): void
    {
        $this->stateMachine->transitionTo(DocumentState::PUBLISHED);
    }
    
    // アーカイブ
    public function archive(): void
    {
        $this->stateMachine->transitionTo(DocumentState::ARCHIVED);
    }
    
    // 状態変更ログの記録
    private function logStateChange(DocumentState $from, DocumentState $to, array $context): void
    {
        // データベースやログファイルに記録する処理
    }
    
    // 購読者に通知
    private function notifySubscribers(): void
    {
        // 通知処理
    }
}

ステートマシン構築のベストプラクティス

  1. 状態をEnumで定義する
    • 各状態を明確に命名し、タイプセーフに使用
    • 状態に関連するメタデータやメソッドをEnumに集約
  2. 遷移ルールを明示的に定義する
    • canTransitionToメソッドで許可される遷移を明確に
    • 複雑なルールはガード条件として分離
  3. コンテキスト情報を遷移に含める
    • 遷移の理由や付随データを引き渡せるようにする
    • 履歴やログに詳細情報を含められる
  4. リスナーパターンでイベント駆動にする
    • 遷移前後の処理を柔軟に追加・削除可能に
    • 関心の分離を促進
  5. 複合状態の表現
    • サブステートや並行状態が必要な場合は階層的ステートマシンを検討
    • 複数の状態軸を持つ場合は、それぞれをEnumで定義

Enumを使ったステートマシンは、ビジネスプロセスの流れを明確に表現し、不正な状態遷移を防止する強力なツールです。PHPアプリケーションの品質と保守性を向上させるために、積極的に活用することをお勧めします。

実践例2:バリデーションとデータ整合性の確保

データバリデーションと整合性の確保は、あらゆるアプリケーションの信頼性を保証する上で非常に重要です。PHPのEnumを活用することで、ドメイン固有の制約を型として表現し、アプリケーション全体で一貫したバリデーションを実現できます。

従来のバリデーション手法の課題

従来のPHPでのバリデーション手法には、以下のような課題がありました:

  1. 値の有効性チェックが分散 – 同じチェックロジックがコード内の複数箇所に散在
  2. ドメイン知識の暗黙化 – 有効な値の定義がコメントやドキュメントに依存
  3. 整合性の保証の難しさ – 入力、処理、保存の各層で一貫した検証が困難
  4. エラーメッセージの不一致 – 同じエラーに対して異なるメッセージが表示される可能性

Enumによるバリデーションの利点

Enumを使用することで、これらの課題を解決できます:

  1. 型による保証 – 有効な値の集合を型として定義
  2. ドメイン知識の明示化 – 許容される値とその意味がコード内で明確
  3. 一貫したバリデーション – アプリケーション全体で統一された検証
  4. エラーハンドリングの標準化 – 一貫性のあるエラーメッセージとハンドリング

実装例:フォーム入力のバリデーション

ユーザープロファイルの登録フォームを例に、Enumを使ったバリデーションを見ていきましょう:

// ユーザータイプの定義
enum UserType: string
{
    case INDIVIDUAL = 'individual';
    case BUSINESS = 'business';
    case NONPROFIT = 'nonprofit';
    
    // 表示用ラベル
    public function getLabel(): string
    {
        return match($this) {
            self::INDIVIDUAL => '個人',
            self::BUSINESS => '企業',
            self::NONPROFIT => '非営利団体',
        };
    }
    
    // 全ケースを選択肢として取得
    public static function getOptions(): array
    {
        $options = [];
        foreach (self::cases() as $case) {
            $options[$case->value] = $case->getLabel();
        }
        return $options;
    }
    
    // バリデーションメッセージ
    public static function getValidationMessage(): string
    {
        return 'ユーザータイプは ' . implode(', ', array_column(self::cases(), 'value')) . ' のいずれかである必要があります。';
    }
}

// 会員ステータスの定義
enum MembershipStatus: string
{
    case PENDING = 'pending';
    case ACTIVE = 'active';
    case SUSPENDED = 'suspended';
    case CANCELLED = 'cancelled';
    
    // 有効なステータスかどうかを確認
    public function isActive(): bool
    {
        return $this === self::ACTIVE;
    }
    
    // 管理者が変更可能なステータス一覧
    public static function getAdminEditableStatuses(): array
    {
        return [self::ACTIVE, self::SUSPENDED];
    }
    
    // ユーザーが選択可能なステータス一覧(解約時など)
    public static function getUserSelectableStatuses(): array
    {
        return [self::ACTIVE, self::CANCELLED];
    }
}

// フォームリクエストのバリデーション(Laravel使用例)
class UserProfileRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name' => 'required|string|max:255',
            'email' => 'required|email|unique:users',
            'user_type' => [
                'required',
                Rule::in(array_column(UserType::cases(), 'value'))
            ],
            'membership_status' => [
                'sometimes',
                Rule::in(array_column(MembershipStatus::cases(), 'value'))
            ]
        ];
    }
    
    public function messages(): array
    {
        return [
            'user_type.in' => UserType::getValidationMessage(),
        ];
    }
    
    // バリデーション済みデータをEnum型に変換
    public function validatedEnums(): array
    {
        $validated = $this->validated();
        
        if (isset($validated['user_type'])) {
            $validated['user_type'] = UserType::from($validated['user_type']);
        }
        
        if (isset($validated['membership_status'])) {
            $validated['membership_status'] = MembershipStatus::from($validated['membership_status']);
        }
        
        return $validated;
    }
}

コントローラーでの使用例

class UserController extends Controller
{
    public function store(UserProfileRequest $request)
    {
        // バリデーション済みデータをEnum型として取得
        $validatedData = $request->validatedEnums();
        
        // Enumオブジェクトとして利用可能
        $userType = $validatedData['user_type'];
        
        // 型安全な比較
        if ($userType === UserType::BUSINESS) {
            // 企業向け追加処理
        }
        
        // 表示用ラベルを取得
        $typeLabel = $userType->getLabel();
        
        // ユーザー作成
        $user = User::create([
            'name' => $validatedData['name'],
            'email' => $validatedData['email'],
            'user_type' => $userType,  // DBキャスト経由で保存
            'membership_status' => MembershipStatus::PENDING,
        ]);
        
        return redirect()->route('users.show', $user)
            ->with('success', "ユーザーを作成しました。タイプ:{$typeLabel}");
    }
}

フレームワーク非依存のバリデーションクラス

Laravelなどのフレームワークを使用しない場合でも、Enumを活用したバリデーションクラスを実装できます:

class Validator
{
    protected array $errors = [];
    
    // Enum型のバリデーション
    public function validateEnum(string $value, string $enumClass, string $fieldName): bool
    {
        try {
            $enumClass::from($value);
            return true;
        } catch (\ValueError $e) {
            $validValues = implode(', ', array_column($enumClass::cases(), 'value'));
            $this->errors[$fieldName] = "{$fieldName}は {$validValues} のいずれかである必要があります。";
            return false;
        }
    }
    
    // オプショナルなEnum型のバリデーション
    public function validateOptionalEnum(?string $value, string $enumClass, string $fieldName): bool
    {
        if ($value === null || $value === '') {
            return true;
        }
        return $this->validateEnum($value, $enumClass, $fieldName);
    }
    
    // バリデーションエラーの取得
    public function getErrors(): array
    {
        return $this->errors;
    }
    
    // 有効性チェック
    public function isValid(): bool
    {
        return empty($this->errors);
    }
}

// 使用例
$validator = new Validator();
$isValid = $validator->validateEnum($_POST['user_type'], UserType::class, 'ユーザータイプ');
if (!$validator->isValid()) {
    $errors = $validator->getErrors();
    // エラー表示処理
}

データベースとの整合性確保

Enumを使用することで、データベースとの間でも整合性を確保できます:

// EloquentモデルでのEnum使用(Laravel 8.x以降)
class User extends Model
{
    protected $fillable = [
        'name', 'email', 'user_type', 'membership_status'
    ];
    
    // Enumキャスト
    protected $casts = [
        'user_type' => UserType::class,
        'membership_status' => MembershipStatus::class,
    ];
    
    // 会員が有効かどうかをチェック
    public function isActiveMember(): bool
    {
        return $this->membership_status->isActive();
    }
}

// マイグレーション
public function up()
{
    Schema::create('users', function (Blueprint $table) {
        $table->id();
        $table->string('name');
        $table->string('email')->unique();
        $table->string('user_type');  // Enumの文字列値を保存
        $table->string('membership_status')->default(MembershipStatus::PENDING->value);
        $table->timestamps();
    });
}

APIレスポンスでの整合性確保

API応答でもEnumを一貫して使用することで整合性を高めます:

class UserResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'email' => $this->email,
            'user_type' => [
                'code' => $this->user_type->value,
                'label' => $this->user_type->getLabel(),
            ],
            'membership_status' => [
                'code' => $this->membership_status->value,
                'is_active' => $this->membership_status->isActive(),
            ],
            'created_at' => $this->created_at,
            'updated_at' => $this->updated_at,
        ];
    }
}

バリデーション実装のベストプラクティス

  1. 入力値→Enum変換のカプセル化
    • tryFromメソッドを活用して安全に変換
    • 検証と変換を一箇所に集約
  2. 厳密な型チェックの活用
    • 可能な限り型宣言を使用
    • PHPStanなどの静的解析ツールと連携
  3. ドメイン知識の集約
    • バリデーションルールとメッセージをEnumに含める
    • 関連する振る舞いも同じ場所にカプセル化
  4. エラーハンドリングの標準化
    • 一貫したエラーメッセージフォーマット
    • 多言語対応の考慮

Enumを使ったバリデーションと整合性確保は、特に複雑なドメインルールや事業拡大による要件変更が頻繁な場面で、その真価を発揮します。型安全性を活かすことで、開発チーム全体が一貫したルールで開発でき、バグの早期発見とコードの品質向上が実現できるでしょう。

データベース連携時のEnumの扱い方

PHPのEnumはメモリ上のオブジェクトですが、データベースには文字列や整数として保存する必要があります。この連携を効率的に行うことで、型安全性を維持しつつ、データベースの整合性も確保できます。

データベース設計の考慮点

Enumとデータベースを連携する際の基本的な考慮点は以下の通りです:

  1. カラム型の選択
    • 文字列型Enum:VARCHAR/TEXTを使用
    • 整数型Enum:INTEGER/SMALLINTを使用
    • 可読性と将来の拡張性のバランスを考慮
  2. 制約の設定
    • CHECK制約またはENUM型(MySQLの場合)で有効値を制限
    • 外部キー制約との組み合わせ
  3. インデックス戦略
    • 頻繁に検索・フィルタリングされるEnum値カラムにはインデックスを設定

フレームワーク別のEnum対応

Laravel (8.x以降)

Laravelは$castsプロパティを通じて、Enumとデータベースの連携をシームレスにサポートしています:

// モデルでのEnum型キャスト
class Product extends Model
{
    protected $fillable = ['name', 'category', 'status'];
    
    protected $casts = [
        'category' => ProductCategory::class,
        'status' => ProductStatus::class
    ];
}

// 使用例
$product = Product::find(1);
echo $product->category->getLabel(); // Enumのメソッドを直接呼び出し

// クエリビルダでの使用
$electronicsProducts = Product::where('category', ProductCategory::ELECTRONICS)->get();

// 作成時
Product::create([
    'name' => 'スマートフォン',
    'category' => ProductCategory::ELECTRONICS,
    'status' => ProductStatus::ACTIVE
]);

Doctrine ORM

Doctrine 2.6以降では、カスタム型を通じてEnumをサポートしています:

// Enumタイプの定義
use Doctrine\DBAL\Types\Type;

class ProductCategoryType extends Type
{
    public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
    {
        return $platform->getVarcharTypeDeclarationSQL([
            'length' => 50,
            'fixed' => false,
        ]);
    }
    
    public function convertToDatabaseValue($value, AbstractPlatform $platform): mixed
    {
        return $value instanceof ProductCategory ? $value->value : $value;
    }
    
    public function convertToPHPValue($value, AbstractPlatform $platform): ?ProductCategory
    {
        return $value !== null ? ProductCategory::tryFrom($value) : null;
    }
    
    public function getName(): string
    {
        return 'product_category';
    }
}

// 型の登録
Type::addType('product_category', ProductCategoryType::class);

// エンティティでの使用
/**
 * @Entity
 */
class Product
{
    /**
     * @Column(type="product_category")
     */
    private ProductCategory $category;
    
    // ゲッター・セッター
}

Symfony Forms

Symfony 6.0以降では、FormTypeでEnumを直接サポートしています:

// フォームタイプ定義
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;

class ProductFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('name', TextType::class)
            ->add('category', EnumType::class, [
                'class' => ProductCategory::class,
                'choice_label' => function (ProductCategory $choice) {
                    return $choice->getLabel();
                }
            ]);
    }
}

フレームワークに依存しない実装

フレームワークを使用しない場合や、レガシーコードとの統合では、独自のマッピングロジックを実装します:

// PDOを使用した例
class ProductRepository
{
    private PDO $pdo;
    
    public function __construct(PDO $pdo) 
    {
        $this->pdo = $pdo;
    }
    
    // データの取得時にEnumに変換
    public function findById(int $id): ?Product
    {
        $stmt = $this->pdo->prepare('SELECT * FROM products WHERE id = ?');
        $stmt->execute([$id]);
        $data = $stmt->fetch(PDO::FETCH_ASSOC);
        
        if (!$data) {
            return null;
        }
        
        $product = new Product();
        $product->setId($data['id']);
        $product->setName($data['name']);
        
        // 文字列からEnumに変換
        if ($data['category']) {
            $product->setCategory(ProductCategory::from($data['category']));
        }
        
        return $product;
    }
    
    // データの保存時にEnumから変換
    public function save(Product $product): void
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO products (name, category) VALUES (?, ?) 
             ON DUPLICATE KEY UPDATE name = ?, category = ?'
        );
        
        // Enumを文字列に変換
        $categoryValue = $product->getCategory() ? $product->getCategory()->value : null;
        
        $stmt->execute([
            $product->getName(),
            $categoryValue,
            $product->getName(),
            $categoryValue
        ]);
        
        if (!$product->getId()) {
            $product->setId($this->pdo->lastInsertId());
        }
    }
    
    // Enumによるフィルタリング
    public function findByCategory(ProductCategory $category): array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM products WHERE category = ?');
        $stmt->execute([$category->value]);
        
        $products = [];
        while ($data = $stmt->fetch(PDO::FETCH_ASSOC)) {
            $product = new Product();
            $product->setId($data['id']);
            $product->setName($data['name']);
            $product->setCategory(ProductCategory::from($data['category']));
            $products[] = $product;
        }
        
        return $products;
    }
}

マイグレーションとスキーマ設計

データベースのマイグレーションでは、Enumの値に対して適切な制約を設定することが重要です:

// Laravelでのマイグレーション例
public function up()
{
    Schema::create('products', function (Blueprint $table) {
        $table->id();
        $table->string('name');
        $table->string('category');
        $table->string('status');
        $table->timestamps();
        
        // チェック制約(PostgreSQLなど)
        $validCategories = implode("','", array_column(ProductCategory::cases(), 'value'));
        $table->whereRaw("category IN ('$validCategories')");
    });
}

// MySQLの場合はENUM型も利用可能
// $table->enum('category', array_column(ProductCategory::cases(), 'value'));

バージョン管理と変更への対応

Enumの値が変更されることを考慮したデータベース設計も重要です:

  1. 新しいEnum値の追加
    • 通常は問題なく追加可能
    • 既存データとの整合性チェック
  2. Enum値の名前変更
    • データ移行が必要
    • 一時的な両対応期間の設計
  3. Enum値の削除
    • 既存データの取り扱いポリシーの決定
    • 代替値への移行計画
// Enum値の変更管理例(Laravelマイグレーション)
public function up()
{
    // 古いカテゴリー値を新しい値に更新
    DB::table('products')
        ->where('category', 'old_electronics')
        ->update(['category' => 'electronics']);
}

データベース連携のベストプラクティス

  1. 一貫したマッピング戦略
    • アプリケーション全体で統一したマッピング方法を使用
    • リポジトリパターンなどでデータ変換ロジックを集約
  2. NULL値の適切な処理
    • 必須フィールドと任意フィールドの区別
    • tryFromメソッドを活用した安全な変換
  3. データベースの制約とアプリケーションの検証の両方を活用
    • 二重の防御線としてのバリデーション
    • データベースレベルの整合性保証
  4. パフォーマンス最適化
    • 頻繁に使用するEnum値カラムにはインデックスを設定
    • N+1クエリ問題の回避

Enumとデータベースの適切な連携により、アプリケーション全体でドメインロジックの一貫性を保ち、データ整合性を確保することができます。フレームワークのサポートを活用しつつ、データベース設計にも配慮することで、メンテナンス性の高いシステムを構築できるでしょう。

実践例3:ポリシーとパーミッション管理

アプリケーションにおける権限管理は、複雑で誤りが許されない重要な要素です。PHPのEnum型を活用することで、型安全かつ保守性の高い権限システムを実現できます。

権限管理の従来の課題

従来の権限管理には以下のような課題がありました:

  1. 文字列ベースの権限チェック – タイプミスによるセキュリティホール
  2. 条件分岐の複雑化 – 権限ロジックの散在と重複
  3. ドキュメンテーションとコードの乖離 – 権限定義の集約場所の欠如
  4. 拡張性の制限 – 新しい権限追加時の変更箇所の多さ

Enumによる権限管理のアプローチ

Enumを使用した権限管理には主に以下のアプローチがあります:

  1. ロールEnum – ユーザーロールを表現
  2. 権限Enum – 個別の権限を表現
  3. ビットフラグEnum – 複数権限の組み合わせを効率的に表現

実装例:ロールベースの権限管理

最もシンプルなアプローチとして、ユーザーロールをEnumで定義します:

enum UserRole: string
{
    case ADMIN = 'admin';             // 管理者
    case EDITOR = 'editor';           // 編集者
    case AUTHOR = 'author';           // 著者
    case SUBSCRIBER = 'subscriber';   // 購読者
    
    // ロールの階層レベルを取得
    public function getLevel(): int
    {
        return match($this) {
            self::ADMIN => 100,
            self::EDITOR => 75,
            self::AUTHOR => 50,
            self::SUBSCRIBER => 25,
        };
    }
    
    // あるロールが別のロール以上の権限を持つかどうか
    public function hasAtLeastRole(self $role): bool
    {
        return $this->getLevel() >= $role->getLevel();
    }
    
    // 特定の操作が許可されているかどうか
    public function canPerformAction(string $action): bool
    {
        return match($action) {
            'manage_users' => $this === self::ADMIN,
            'edit_settings' => $this->hasAtLeastRole(self::ADMIN),
            'publish_content' => $this->hasAtLeastRole(self::EDITOR),
            'edit_own_content' => $this->hasAtLeastRole(self::AUTHOR),
            'comment' => $this->hasAtLeastRole(self::SUBSCRIBER),
            'view_content' => true,
            default => false,
        };
    }
    
    // 表示名を取得
    public function getDisplayName(): string
    {
        return match($this) {
            self::ADMIN => '管理者',
            self::EDITOR => '編集者',
            self::AUTHOR => '著者',
            self::SUBSCRIBER => '購読者',
        };
    }
}

// 使用例
class User
{
    private int $id;
    private string $name;
    private string $email;
    private UserRole $role;
    
    // ゲッターとセッター
    
    public function hasPermission(string $action): bool
    {
        return $this->role->canPerformAction($action);
    }
}

// アクセス制御の実装
if ($user->hasPermission('publish_content')) {
    // コンテンツを公開する処理
} else {
    throw new AuthorizationException('コンテンツを公開する権限がありません');
}

実装例:細粒度の権限Enum

より柔軟な権限システムのために、個別の権限をEnumで定義します:

enum Permission: string
{
    // ユーザー管理権限
    case MANAGE_USERS = 'manage_users';
    case VIEW_USERS = 'view_users';
    case CREATE_USER = 'create_user';
    case EDIT_USER = 'edit_user';
    case DELETE_USER = 'delete_user';
    
    // コンテンツ管理権限
    case MANAGE_CONTENT = 'manage_content';
    case VIEW_CONTENT = 'view_content';
    case CREATE_CONTENT = 'create_content';
    case EDIT_OWN_CONTENT = 'edit_own_content';
    case EDIT_ANY_CONTENT = 'edit_any_content';
    case PUBLISH_CONTENT = 'publish_content';
    case DELETE_OWN_CONTENT = 'delete_own_content';
    case DELETE_ANY_CONTENT = 'delete_any_content';
    
    // システム管理権限
    case MANAGE_SETTINGS = 'manage_settings';
    case VIEW_LOGS = 'view_logs';
    
    // 権限の説明を取得
    public function getDescription(): string
    {
        return match($this) {
            self::MANAGE_USERS => 'ユーザー管理(全て)',
            self::VIEW_USERS => 'ユーザー情報の閲覧',
            self::CREATE_USER => 'ユーザーの作成',
            self::EDIT_USER => 'ユーザー情報の編集',
            self::DELETE_USER => 'ユーザーの削除',
            self::MANAGE_CONTENT => 'コンテンツ管理(全て)',
            self::VIEW_CONTENT => 'コンテンツの閲覧',
            self::CREATE_CONTENT => 'コンテンツの作成',
            self::EDIT_OWN_CONTENT => '自分のコンテンツの編集',
            self::EDIT_ANY_CONTENT => '全てのコンテンツの編集',
            self::PUBLISH_CONTENT => 'コンテンツの公開',
            self::DELETE_OWN_CONTENT => '自分のコンテンツの削除',
            self::DELETE_ANY_CONTENT => '全てのコンテンツの削除',
            self::MANAGE_SETTINGS => 'システム設定の管理',
            self::VIEW_LOGS => 'システムログの閲覧',
        };
    }
    
    // 親権限をチェック(包含関係)
    public function impliesPermission(self $permission): bool
    {
        // 特定の権限が他の権限を包含する関係を定義
        if ($this === self::MANAGE_USERS) {
            return in_array($permission, [
                self::VIEW_USERS, self::CREATE_USER, self::EDIT_USER, self::DELETE_USER
            ]);
        }
        
        if ($this === self::MANAGE_CONTENT) {
            return in_array($permission, [
                self::VIEW_CONTENT, self::CREATE_CONTENT, 
                self::EDIT_OWN_CONTENT, self::EDIT_ANY_CONTENT,
                self::PUBLISH_CONTENT, 
                self::DELETE_OWN_CONTENT, self::DELETE_ANY_CONTENT
            ]);
        }
        
        if ($this === self::EDIT_ANY_CONTENT) {
            return $permission === self::EDIT_OWN_CONTENT;
        }
        
        if ($this === self::DELETE_ANY_CONTENT) {
            return $permission === self::DELETE_OWN_CONTENT;
        }
        
        // 同じ権限の場合も含意関係あり
        return $this === $permission;
    }
}

// ロールと権限のマッピング
class Role
{
    private string $name;
    private array $permissions = [];
    
    public function __construct(string $name, array $permissions = [])
    {
        $this->name = $name;
        $this->permissions = $permissions;
    }
    
    public function hasPermission(Permission $permission): bool
    {
        foreach ($this->permissions as $rolePermission) {
            if ($rolePermission->impliesPermission($permission)) {
                return true;
            }
        }
        
        return false;
    }
}

// 使用例
$adminRole = new Role('管理者', [
    Permission::MANAGE_USERS,
    Permission::MANAGE_CONTENT,
    Permission::MANAGE_SETTINGS,
    Permission::VIEW_LOGS
]);

$editorRole = new Role('編集者', [
    Permission::VIEW_USERS,
    Permission::VIEW_CONTENT,
    Permission::CREATE_CONTENT,
    Permission::EDIT_ANY_CONTENT,
    Permission::PUBLISH_CONTENT
]);

$authorRole = new Role('著者', [
    Permission::VIEW_CONTENT,
    Permission::CREATE_CONTENT,
    Permission::EDIT_OWN_CONTENT,
    Permission::DELETE_OWN_CONTENT
]);

// 権限チェック
if ($editorRole->hasPermission(Permission::PUBLISH_CONTENT)) {
    // 編集者はコンテンツを公開できる
}

if (!$authorRole->hasPermission(Permission::PUBLISH_CONTENT)) {
    // 著者はコンテンツを公開できない
}

実装例:ビットフラグを使った効率的な権限管理

多数の権限を効率的に扱うために、整数値を使ったビットフラグパターンが有効です:

enum PermissionFlag: int
{
    case NONE = 0;              // 0000 0000
    case VIEW = 1;              // 0000 0001
    case CREATE = 2;            // 0000 0010
    case EDIT = 4;              // 0000 0100
    case DELETE = 8;            // 0000 1000
    case PUBLISH = 16;          // 0001 0000
    case MANAGE = 32;           // 0010 0000
    case ADMIN = 64;            // 0100 0000
    
    // 便利な組み合わせ
    case BASIC = 3;             // VIEW | CREATE
    case EDITOR = 7;            // VIEW | CREATE | EDIT
    case MODERATOR = 31;        // VIEW | CREATE | EDIT | DELETE | PUBLISH
    case SUPER_ADMIN = 127;     // 全ての権限
    
    // 特定の権限があるかチェック
    public function hasFlag(self $flag): bool
    {
        // ビット演算でフラグの存在をチェック
        return ($this->value & $flag->value) === $flag->value;
    }
    
    // 複数のフラグを組み合わせた新しいPermissionFlagを取得
    public function add(self $flag): self
    {
        $newValue = $this->value | $flag->value;
        return self::from($newValue);
    }
    
    // 特定のフラグを除去した新しいPermissionFlagを取得
    public function remove(self $flag): self
    {
        $newValue = $this->value & ~$flag->value;
        return self::from($newValue);
    }
}

// リソースタイプを定義
enum ResourceType: string
{
    case USER = 'user';
    case ARTICLE = 'article';
    case COMMENT = 'comment';
    case SETTING = 'setting';
}

// ユーザークラスでの実装
class User
{
    private array $permissions = [];
    
    public function __construct()
    {
        // デフォルトですべてのリソースにNONE権限を設定
        foreach (ResourceType::cases() as $resourceType) {
            $this->permissions[$resourceType->value] = PermissionFlag::NONE;
        }
    }
    
    // リソースタイプに対する権限を設定
    public function setPermission(ResourceType $resourceType, PermissionFlag $permission): void
    {
        $this->permissions[$resourceType->value] = $permission;
    }
    
    // リソースタイプに対する権限を取得
    public function getPermission(ResourceType $resourceType): PermissionFlag
    {
        return $this->permissions[$resourceType->value] ?? PermissionFlag::NONE;
    }
    
    // リソースに対する特定の操作が許可されているかチェック
    public function can(ResourceType $resourceType, PermissionFlag $permission): bool
    {
        $resourcePermission = $this->getPermission($resourceType);
        return $resourcePermission->hasFlag($permission);
    }
}

// 使用例
$user = new User();
$user->setPermission(ResourceType::ARTICLE, PermissionFlag::EDITOR);
$user->setPermission(ResourceType::COMMENT, PermissionFlag::MODERATOR);

// 権限チェック
if ($user->can(ResourceType::ARTICLE, PermissionFlag::EDIT)) {
    // 記事の編集が許可されている
}

if (!$user->can(ResourceType::ARTICLE, PermissionFlag::PUBLISH)) {
    // 記事の公開は許可されていない
}

if ($user->can(ResourceType::COMMENT, PermissionFlag::DELETE)) {
    // コメントの削除が許可されている
}

条件付き権限ポリシーの実装

特定の条件に基づく権限チェックを実装する場合は、Enumと組み合わせたポリシークラスが効果的です:

// 抽象ポリシークラス
abstract class Policy
{
    abstract public function can(User $user, string $action, object $resource): bool;
}

// 記事に対するポリシー
class ArticlePolicy extends Policy
{
    public function can(User $user, string $action, object $resource): bool
    {
        // 記事リソースの型チェック
        if (!$resource instanceof Article) {
            return false;
        }
        
        return match($action) {
            'view' => true,  // 誰でも閲覧可能
            'create' => $user->hasPermission(Permission::CREATE_CONTENT),
            'edit' => $this->canEdit($user, $resource),
            'delete' => $this->canDelete($user, $resource),
            'publish' => $user->hasPermission(Permission::PUBLISH_CONTENT),
            default => false,
        };
    }
    
    private function canEdit(User $user, Article $article): bool
    {
        // 自分の記事か、または全ての記事を編集できる権限があるか
        return $article->getAuthorId() === $user->getId() && $user->hasPermission(Permission::EDIT_OWN_CONTENT)
            || $user->hasPermission(Permission::EDIT_ANY_CONTENT);
    }
    
    private function canDelete(User $user, Article $article): bool
    {
        // 自分の記事か、または全ての記事を削除できる権限があるか
        return $article->getAuthorId() === $user->getId() && $user->hasPermission(Permission::DELETE_OWN_CONTENT)
            || $user->hasPermission(Permission::DELETE_ANY_CONTENT);
    }
}

// ポリシーの使用例
$articlePolicy = new ArticlePolicy();
$canEdit = $articlePolicy->can($currentUser, 'edit', $article);

if ($canEdit) {
    // 編集処理
} else {
    throw new AuthorizationException('この記事を編集する権限がありません');
}

権限システムのベストプラクティス

  1. 権限の粒度を適切に設計する
    • 細かすぎると管理が複雑になり、大きすぎると柔軟性が失われる
    • ドメインに応じた適切な抽象化レベルを選択
  2. 階層的な権限モデルを活用する
    • 上位権限が下位権限を包含する関係を明確に定義
    • 権限チェックを効率化
  3. パフォーマンスを考慮する
    • 頻繁な権限チェックはキャッシュを検討
    • ビットフラグを活用して効率化
  4. テスト容易性を確保する
    • 権限ロジックの単体テストを充実させる
    • モックユーザーで様々なシナリオをテスト

Enumを活用した権限管理システムは、型安全性とコードの明確さを両立し、複雑な権限ロジックを整理する強力なツールです。適切に設計することで、拡張性が高く、保守しやすい権限システムを構築できます。

権限管理をEnumで実装する例

権限管理システムをEnumで実装する具体的な例を見ていきましょう。ここでは、コンテンツ管理システム(CMS)を想定した、実践的な権限管理システムを構築します。

権限とユーザーロールのモデリング

まず、基本的な権限とロールをEnum型で定義します:

// 個別の権限を定義するEnum
enum Permission: string
{
    // コンテンツ関連の権限
    case VIEW_CONTENT = 'view_content';
    case CREATE_CONTENT = 'create_content';
    case EDIT_OWN_CONTENT = 'edit_own_content';
    case EDIT_ANY_CONTENT = 'edit_any_content';
    case DELETE_OWN_CONTENT = 'delete_own_content';
    case DELETE_ANY_CONTENT = 'delete_any_content';
    case PUBLISH_CONTENT = 'publish_content';
    
    // メディア関連の権限
    case UPLOAD_MEDIA = 'upload_media';
    case MANAGE_MEDIA = 'manage_media';
    
    // ユーザー管理関連の権限
    case VIEW_USERS = 'view_users';
    case MANAGE_USERS = 'manage_users';
    
    // システム関連の権限
    case MANAGE_SETTINGS = 'manage_settings';
    case VIEW_STATISTICS = 'view_statistics';
    
    // 表示用ラベルを取得
    public function getLabel(): string
    {
        return match($this) {
            self::VIEW_CONTENT => 'コンテンツ閲覧',
            self::CREATE_CONTENT => 'コンテンツ作成',
            self::EDIT_OWN_CONTENT => '自分のコンテンツ編集',
            self::EDIT_ANY_CONTENT => '任意のコンテンツ編集',
            self::DELETE_OWN_CONTENT => '自分のコンテンツ削除',
            self::DELETE_ANY_CONTENT => '任意のコンテンツ削除',
            self::PUBLISH_CONTENT => 'コンテンツ公開',
            self::UPLOAD_MEDIA => 'メディアアップロード',
            self::MANAGE_MEDIA => 'メディア管理',
            self::VIEW_USERS => 'ユーザー閲覧',
            self::MANAGE_USERS => 'ユーザー管理',
            self::MANAGE_SETTINGS => 'システム設定管理',
            self::VIEW_STATISTICS => '統計情報閲覧',
        };
    }
}

// ユーザーロールを定義するEnum
enum UserRole: string
{
    case ADMIN = 'admin';
    case EDITOR = 'editor';
    case AUTHOR = 'author';
    case CONTRIBUTOR = 'contributor';
    case SUBSCRIBER = 'subscriber';
    
    // ロールに割り当てられる権限の配列を取得
    public function getPermissions(): array
    {
        return match($this) {
            self::ADMIN => [
                Permission::VIEW_CONTENT,
                Permission::CREATE_CONTENT,
                Permission::EDIT_ANY_CONTENT,
                Permission::DELETE_ANY_CONTENT,
                Permission::PUBLISH_CONTENT,
                Permission::UPLOAD_MEDIA,
                Permission::MANAGE_MEDIA,
                Permission::VIEW_USERS,
                Permission::MANAGE_USERS,
                Permission::MANAGE_SETTINGS,
                Permission::VIEW_STATISTICS,
            ],
            self::EDITOR => [
                Permission::VIEW_CONTENT,
                Permission::CREATE_CONTENT,
                Permission::EDIT_ANY_CONTENT,
                Permission::DELETE_ANY_CONTENT,
                Permission::PUBLISH_CONTENT,
                Permission::UPLOAD_MEDIA,
                Permission::MANAGE_MEDIA,
                Permission::VIEW_USERS,
                Permission::VIEW_STATISTICS,
            ],
            self::AUTHOR => [
                Permission::VIEW_CONTENT,
                Permission::CREATE_CONTENT,
                Permission::EDIT_OWN_CONTENT,
                Permission::DELETE_OWN_CONTENT,
                Permission::UPLOAD_MEDIA,
                Permission::VIEW_STATISTICS,
            ],
            self::CONTRIBUTOR => [
                Permission::VIEW_CONTENT,
                Permission::CREATE_CONTENT,
                Permission::EDIT_OWN_CONTENT,
                Permission::UPLOAD_MEDIA,
            ],
            self::SUBSCRIBER => [
                Permission::VIEW_CONTENT,
            ],
        };
    }
    
    // 表示用ラベルを取得
    public function getLabel(): string
    {
        return match($this) {
            self::ADMIN => '管理者',
            self::EDITOR => '編集者',
            self::AUTHOR => '著者',
            self::CONTRIBUTOR => '寄稿者',
            self::SUBSCRIBER => '購読者',
        };
    }
    
    // 特定の権限を持っているかチェック
    public function hasPermission(Permission $permission): bool
    {
        return in_array($permission, $this->getPermissions());
    }
}

ユーザー権限マネージャーの実装

次に、複数のロールを持つユーザーをサポートするための権限マネージャーを実装します:

class PermissionManager
{
    private array $userRoles = [];
    private array $customPermissions = [];
    private array $permissionCache = [];
    
    // ユーザーにロールを割り当て
    public function assignRoleToUser(int $userId, UserRole $role): void
    {
        if (!isset($this->userRoles[$userId])) {
            $this->userRoles[$userId] = [];
        }
        
        $this->userRoles[$userId][] = $role;
        
        // キャッシュをクリア
        unset($this->permissionCache[$userId]);
    }
    
    // ユーザーにカスタム権限を割り当て
    public function assignPermissionToUser(int $userId, Permission $permission, bool $granted = true): void
    {
        if (!isset($this->customPermissions[$userId])) {
            $this->customPermissions[$userId] = [];
        }
        
        $this->customPermissions[$userId][$permission->value] = $granted;
        
        // キャッシュをクリア
        unset($this->permissionCache[$userId]);
    }
    
    // ユーザーが特定の権限を持っているかチェック
    public function userHasPermission(int $userId, Permission $permission): bool
    {
        // キャッシュから結果を取得
        if (isset($this->permissionCache[$userId][$permission->value])) {
            return $this->permissionCache[$userId][$permission->value];
        }
        
        // カスタム権限をチェック(明示的な拒否が優先)
        if (isset($this->customPermissions[$userId][$permission->value]) && 
            $this->customPermissions[$userId][$permission->value] === false) {
            $result = false;
            $this->cachePermissionResult($userId, $permission, $result);
            return $result;
        }
        
        // カスタム権限で付与されているかチェック
        if (isset($this->customPermissions[$userId][$permission->value]) && 
            $this->customPermissions[$userId][$permission->value] === true) {
            $result = true;
            $this->cachePermissionResult($userId, $permission, $result);
            return $result;
        }
        
        // ロールに基づいて権限をチェック
        if (isset($this->userRoles[$userId])) {
            foreach ($this->userRoles[$userId] as $role) {
                if ($role->hasPermission($permission)) {
                    $result = true;
                    $this->cachePermissionResult($userId, $permission, $result);
                    return $result;
                }
            }
        }
        
        $result = false;
        $this->cachePermissionResult($userId, $permission, $result);
        return $result;
    }
    
    // パフォーマンス向上のために結果をキャッシュ
    private function cachePermissionResult(int $userId, Permission $permission, bool $result): void
    {
        if (!isset($this->permissionCache[$userId])) {
            $this->permissionCache[$userId] = [];
        }
        
        $this->permissionCache[$userId][$permission->value] = $result;
    }
}

コンテキスト依存の権限チェック

より複雑なケースでは、コンテキスト(リソースの所有権など)に応じた権限チェックが必要です:

class ContentAuthorization
{
    private PermissionManager $permissionManager;
    
    public function __construct(PermissionManager $permissionManager)
    {
        $this->permissionManager = $permissionManager;
    }
    
    // コンテンツに対する操作が許可されているかチェック
    public function canPerformOnContent(int $userId, string $action, Content $content): bool
    {
        return match($action) {
            'view' => $this->canViewContent($userId, $content),
            'edit' => $this->canEditContent($userId, $content),
            'delete' => $this->canDeleteContent($userId, $content),
            'publish' => $this->canPublishContent($userId, $content),
            default => false,
        };
    }
    
    private function canViewContent(int $userId, Content $content): bool
    {
        // 公開済みコンテンツは閲覧可能
        if ($content->isPublished()) {
            return $this->permissionManager->userHasPermission($userId, Permission::VIEW_CONTENT);
        }
        
        // 未公開コンテンツは編集権限があれば閲覧可能
        return $this->canEditContent($userId, $content);
    }
    
    private function canEditContent(int $userId, Content $content): bool
    {
        // 任意のコンテンツ編集権限
        if ($this->permissionManager->userHasPermission($userId, Permission::EDIT_ANY_CONTENT)) {
            return true;
        }
        
        // 自分のコンテンツの編集権限
        return $content->getAuthorId() === $userId && 
               $this->permissionManager->userHasPermission($userId, Permission::EDIT_OWN_CONTENT);
    }
    
    private function canDeleteContent(int $userId, Content $content): bool
    {
        // 任意のコンテンツ削除権限
        if ($this->permissionManager->userHasPermission($userId, Permission::DELETE_ANY_CONTENT)) {
            return true;
        }
        
        // 自分のコンテンツの削除権限
        return $content->getAuthorId() === $userId && 
               $this->permissionManager->userHasPermission($userId, Permission::DELETE_OWN_CONTENT);
    }
    
    private function canPublishContent(int $userId, Content $content): bool
    {
        return $this->permissionManager->userHasPermission($userId, Permission::PUBLISH_CONTENT);
    }
}

データベースとの連携

権限情報をデータベースに保存する場合の実装例:

class PermissionRepository
{
    private PDO $pdo;
    
    public function __construct(PDO $pdo)
    {
        $this->pdo = $pdo;
    }
    
    // ユーザーのロールを保存
    public function saveUserRole(int $userId, UserRole $role): void
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO user_roles (user_id, role) VALUES (?, ?) 
             ON DUPLICATE KEY UPDATE role = ?'
        );
        
        $stmt->execute([$userId, $role->value, $role->value]);
    }
    
    // ユーザーのカスタム権限を保存
    public function saveUserPermission(int $userId, Permission $permission, bool $granted): void
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO user_permissions (user_id, permission, granted) VALUES (?, ?, ?) 
             ON DUPLICATE KEY UPDATE granted = ?'
        );
        
        $stmt->execute([$userId, $permission->value, $granted ? 1 : 0, $granted ? 1 : 0]);
    }
    
    // ユーザーのロールを読み込み
    public function loadUserRoles(int $userId): array
    {
        $stmt = $this->pdo->prepare('SELECT role FROM user_roles WHERE user_id = ?');
        $stmt->execute([$userId]);
        
        $roles = [];
        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
            try {
                $roles[] = UserRole::from($row['role']);
            } catch (\ValueError $e) {
                // 無効なロール値はスキップ
                error_log("Invalid role value: {$row['role']}");
            }
        }
        
        return $roles;
    }
    
    // ユーザーのカスタム権限を読み込み
    public function loadUserPermissions(int $userId): array
    {
        $stmt = $this->pdo->prepare('SELECT permission, granted FROM user_permissions WHERE user_id = ?');
        $stmt->execute([$userId]);
        
        $permissions = [];
        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
            try {
                $permission = Permission::from($row['permission']);
                $permissions[$permission->value] = (bool)$row['granted'];
            } catch (\ValueError $e) {
                // 無効な権限値はスキップ
                error_log("Invalid permission value: {$row['permission']}");
            }
        }
        
        return $permissions;
    }
}

権限管理システムの使用例

実際のアプリケーションでこの権限管理システムを使用する例:

// アプリケーションでの使用例
class ApplicationService
{
    private PermissionManager $permissionManager;
    private ContentAuthorization $contentAuth;
    private PermissionRepository $permissionRepo;
    
    public function __construct(
        PermissionManager $permissionManager,
        ContentAuthorization $contentAuth,
        PermissionRepository $permissionRepo
    ) {
        $this->permissionManager = $permissionManager;
        $this->contentAuth = $contentAuth;
        $this->permissionRepo = $permissionRepo;
    }
    
    // アプリケーション起動時にユーザー権限を読み込み
    public function initializeUserPermissions(int $userId): void
    {
        // データベースからロールとカスタム権限を読み込み
        $roles = $this->permissionRepo->loadUserRoles($userId);
        $customPermissions = $this->permissionRepo->loadUserPermissions($userId);
        
        // 権限マネージャーに設定
        foreach ($roles as $role) {
            $this->permissionManager->assignRoleToUser($userId, $role);
        }
        
        foreach ($customPermissions as $permissionName => $granted) {
            $permission = Permission::from($permissionName);
            $this->permissionManager->assignPermissionToUser($userId, $permission, $granted);
        }
    }
    
    // コンテンツ編集ページへのアクセス制御
    public function handleEditContentRequest(int $userId, int $contentId): Response
    {
        $content = $this->contentRepository->findById($contentId);
        
        if (!$content) {
            return new Response('Content not found', 404);
        }
        
        if (!$this->contentAuth->canPerformOnContent($userId, 'edit', $content)) {
            return new Response('Permission denied', 403);
        }
        
        // 権限チェックに通過した場合、編集ページを表示
        return new Response(
            $this->renderEditForm($content)
        );
    }
    
    // 管理画面メニューの権限に基づく表示制御
    public function buildAdminMenu(int $userId): array
    {
        $menu = [];
        
        // 基本的なメニュー項目
        $menu[] = [
            'title' => 'ダッシュボード',
            'url' => '/admin/dashboard',
            'visible' => true // 常に表示
        ];
        
        // コンテンツ管理メニュー
        $menu[] = [
            'title' => 'コンテンツ管理',
            'url' => '/admin/content',
            'visible' => $this->permissionManager->userHasPermission($userId, Permission::VIEW_CONTENT),
            'children' => [
                [
                    'title' => '新規作成',
                    'url' => '/admin/content/create',
                    'visible' => $this->permissionManager->userHasPermission($userId, Permission::CREATE_CONTENT)
                ],
                [
                    'title' => 'コンテンツ一覧',
                    'url' => '/admin/content/list',
                    'visible' => $this->permissionManager->userHasPermission($userId, Permission::VIEW_CONTENT)
                ]
            ]
        ];
        
        // メディア管理メニュー
        $menu[] = [
            'title' => 'メディア管理',
            'url' => '/admin/media',
            'visible' => $this->permissionManager->userHasPermission($userId, Permission::UPLOAD_MEDIA),
            'children' => [
                [
                    'title' => 'アップロード',
                    'url' => '/admin/media/upload',
                    'visible' => $this->permissionManager->userHasPermission($userId, Permission::UPLOAD_MEDIA)
                ],
                [
                    'title' => 'メディアライブラリ',
                    'url' => '/admin/media/library',
                    'visible' => $this->permissionManager->userHasPermission($userId, Permission::MANAGE_MEDIA)
                ]
            ]
        ];
        
        // ユーザー管理メニュー(管理者のみ)
        $menu[] = [
            'title' => 'ユーザー管理',
            'url' => '/admin/users',
            'visible' => $this->permissionManager->userHasPermission($userId, Permission::VIEW_USERS),
            'children' => [
                [
                    'title' => 'ユーザー一覧',
                    'url' => '/admin/users/list',
                    'visible' => $this->permissionManager->userHasPermission($userId, Permission::VIEW_USERS)
                ],
                [
                    'title' => '新規ユーザー追加',
                    'url' => '/admin/users/create',
                    'visible' => $this->permissionManager->userHasPermission($userId, Permission::MANAGE_USERS)
                ],
                [
                    'title' => '権限設定',
                    'url' => '/admin/users/permissions',
                    'visible' => $this->permissionManager->userHasPermission($userId, Permission::MANAGE_USERS)
                ]
            ]
        ];
        
        // システム設定メニュー(管理者のみ)
        $menu[] = [
            'title' => 'システム設定',
            'url' => '/admin/settings',
            'visible' => $this->permissionManager->userHasPermission($userId, Permission::MANAGE_SETTINGS)
        ];
        
        return array_filter($menu, fn($item) => $item['visible']);
    }
}

権限システムの構築に関するベストプラクティス

  1. シンプルさと拡張性のバランス
    • 初期設計は必要最低限にし、後からの拡張を容易にする
    • クラス定数ではなくEnumを使用することで型安全性を確保
  2. パフォーマンス最適化
    • 権限チェックは高頻度で行われるため、キャッシングを実装
    • ビットフラグを使った効率的な権限表現(多数の権限がある場合)
  3. テストと検証
    • 様々なユーザーロールと状況を想定した単体テストの作成
    • エッジケースでの挙動を検証
  4. セキュリティ意識
    • デフォルトで「拒否」し、明示的に許可する設計
    • 権限変更には監査ログを残す仕組み
  5. ドキュメント化
    • Enumの各ケースにコメントを充実させ、自己文書化コードに
    • 権限マップの視覚化と共有

Enumを活用した権限管理システムは、型安全性と明確なコード構造によって、複雑な権限ロジックを管理可能にします。堅牢でメンテナンス性の高い権限システムは、アプリケーションの長期的な進化と品質向上に大きく貢献します。

複雑な条件分岐をEnumでクリーンに書く方法

複雑な条件分岐は、コードの可読性、保守性、テスト容易性を低下させる主要な要因の一つです。PHP Enumを活用することで、複雑な条件ロジックをクリーンで型安全、かつ拡張しやすい形で実装できます。

条件分岐のアンチパターン

まず、複雑な条件分岐の典型的なアンチパターンを見てみましょう:

// 従来の複雑な条件分岐(アンチパターン)
function calculateShippingCost($country, $weight, $method, $isPriority)
{
    // 国に基づく基本料金
    if ($country === 'JP') {
        $baseCost = 1000;
    } elseif ($country === 'US') {
        $baseCost = 2500;
    } elseif (in_array($country, ['GB', 'FR', 'DE', 'IT', 'ES'])) {
        $baseCost = 3000;
    } else {
        $baseCost = 4000;
    }
    
    // 配送方法による係数
    if ($method === 'air') {
        $methodFactor = 1.5;
    } elseif ($method === 'sea') {
        $methodFactor = 0.8;
    } elseif ($method === 'land') {
        if (in_array($country, ['JP', 'CN', 'KR', 'TW'])) {
            // アジア地域の陸送
            $methodFactor = 0.7;
        } else {
            $methodFactor = 1.0;
        }
    } else {
        $methodFactor = 1.0;
    }
    
    // 重量による追加料金
    if ($weight <= 1) {
        $weightCost = 0;
    } elseif ($weight <= 5) {
        $weightCost = 500;
    } elseif ($weight <= 10) {
        $weightCost = 1000;
    } elseif ($weight <= 20) {
        $weightCost = 2000;
    } else {
        $weightCost = 3000 + ($weight - 20) * 100;
    }
    
    // 優先配送の追加料金
    if ($isPriority) {
        if ($method === 'air') {
            $priorityFee = 2000;
        } elseif ($method === 'sea') {
            $priorityFee = 3000;
        } else {
            $priorityFee = 1500;
        }
    } else {
        $priorityFee = 0;
    }
    
    return $baseCost * $methodFactor + $weightCost + $priorityFee;
}

このコードには以下の問題があります:

  1. 可読性の低さ – 入れ子の条件分岐が理解を困難にしている
  2. 拡張性の欠如 – 新しい条件の追加が複数箇所での変更を要求する
  3. テストの難しさ – 全ての条件の組み合わせをテストするのが困難
  4. ビジネスロジックの分散 – 関連する論理が分散している

Enumを使ったリファクタリング

これをEnumを使ってリファクタリングします:

// 国/地域をEnumで表現
enum ShippingRegion: string
{
    case JAPAN = 'JP';
    case USA = 'US';
    case EU = 'EU';     // EUをグループ化
    case OTHER = 'OTHER';
    
    // 国コードからRegionを取得
    public static function fromCountryCode(string $countryCode): self
    {
        if ($countryCode === 'JP') {
            return self::JAPAN;
        } elseif ($countryCode === 'US') {
            return self::USA;
        } elseif (in_array($countryCode, ['GB', 'FR', 'DE', 'IT', 'ES'])) {
            return self::EU;
        } else {
            return self::OTHER;
        }
    }
    
    // 基本料金を取得
    public function getBaseCost(): int
    {
        return match($this) {
            self::JAPAN => 1000,
            self::USA => 2500,
            self::EU => 3000,
            self::OTHER => 4000,
        };
    }
    
    // アジア地域かどうかを判定
    public function isAsiaRegion(): bool
    {
        return $this === self::JAPAN;
    }
}

// 配送方法をEnumで表現
enum ShippingMethod: string
{
    case AIR = 'air';
    case SEA = 'sea';
    case LAND = 'land';
    
    // 地域ごとの係数を取得
    public function getMethodFactor(ShippingRegion $region): float
    {
        return match($this) {
            self::AIR => 1.5,
            self::SEA => 0.8,
            self::LAND => $region->isAsiaRegion() ? 0.7 : 1.0
        };
    }
    
    // 優先配送の追加料金を取得
    public function getPriorityFee(): int
    {
        return match($this) {
            self::AIR => 2000,
            self::SEA => 3000,
            self::LAND => 1500
        };
    }
}

// 重量カテゴリをEnumで表現
enum WeightCategory
{
    case LIGHT;       // 0-1kg
    case MEDIUM;      // 1-5kg
    case HEAVY;       // 5-10kg
    case VERY_HEAVY;  // 10-20kg
    case OVERSIZE;    // 20kg以上
    
    // 重量から適切なカテゴリを取得
    public static function fromWeight(float $weight): self
    {
        if ($weight <= 1) {
            return self::LIGHT;
        } elseif ($weight <= 5) {
            return self::MEDIUM;
        } elseif ($weight <= 10) {
            return self::HEAVY;
        } elseif ($weight <= 20) {
            return self::VERY_HEAVY;
        } else {
            return self::OVERSIZE;
        }
    }
    
    // 重量追加料金を計算
    public function calculateWeightCost(float $weight): int
    {
        return match($this) {
            self::LIGHT => 0,
            self::MEDIUM => 500,
            self::HEAVY => 1000,
            self::VERY_HEAVY => 2000,
            self::OVERSIZE => 3000 + (int)(($weight - 20) * 100)
        };
    }
}

// リファクタリング後の料金計算関数
function calculateShippingCost(string $country, float $weight, string $methodString, bool $isPriority): int
{
    // 入力値をEnum型に変換
    $region = ShippingRegion::fromCountryCode($country);
    $method = ShippingMethod::from($methodString);
    $weightCategory = WeightCategory::fromWeight($weight);
    
    // 基本料金を計算
    $baseCost = $region->getBaseCost();
    
    // 配送方法による係数を適用
    $adjustedBaseCost = (int)($baseCost * $method->getMethodFactor($region));
    
    // 重量による追加料金
    $weightCost = $weightCategory->calculateWeightCost($weight);
    
    // 優先配送の追加料金
    $priorityFee = $isPriority ? $method->getPriorityFee() : 0;
    
    return $adjustedBaseCost + $weightCost + $priorityFee;
}

このリファクタリングには以下のメリットがあります:

  1. 可読性の向上 – 各ロジックが論理的な単位に分離されている
  2. 型安全性 – Enumの使用により型安全性が保証される
  3. 拡張性 – 新しい地域や配送方法の追加が容易
  4. 保守性 – ビジネスルールの変更が該当する箇所のみに影響
  5. テスト容易性 – 各Enumを個別にテスト可能

複雑な条件分岐の整理パターン

Enumを使って条件分岐を整理する一般的なパターンは以下の通りです:

  1. 値の分類とグループ化
    • 関連する値をEnum内に定義
    • 値の変換ロジックをstaticメソッドとして実装
  2. ケースに応じた振る舞いの定義
    • 各Enumケースに対する処理をEnumのメソッドとして実装
    • match式を使用して簡潔に記述
  3. コンテキスト情報の受け渡し
    • 必要に応じてメソッドに追加情報を渡す
    • Enumメソッド間の連携を明示的に設計
  4. バリデーションと型変換の分離
    • 入力値の検証と変換を明確に分ける
    • 無効な入力に対する例外処理を統一

その他の実装テクニック

複雑な条件に対処するさらなるテクニック:

// 複数の要素を組み合わせた条件判定
enum OrderStatus: string
{
    case PENDING = 'pending';
    case PAID = 'paid';
    case SHIPPED = 'shipped';
    case DELIVERED = 'delivered';
    case CANCELLED = 'cancelled';
    
    // 複合的な条件判定の例
    public function canEdit(UserRole $userRole, bool $isOwner): bool
    {
        // ユーザーロールと注文ステータスに基づく編集可否判定
        return match([$this, $userRole, $isOwner]) {
            // 管理者は常に編集可能
            [self::PENDING, UserRole::ADMIN, true] => true,
            [self::PENDING, UserRole::ADMIN, false] => true,
            [self::PAID, UserRole::ADMIN, true] => true,
            [self::PAID, UserRole::ADMIN, false] => true,
            [self::SHIPPED, UserRole::ADMIN, true] => true,
            [self::SHIPPED, UserRole::ADMIN, false] => true,
            
            // 注文所有者は特定のステータスのみ編集可能
            [self::PENDING, UserRole::CUSTOMER, true] => true,
            [self::PAID, UserRole::CUSTOMER, true] => true,
            
            // それ以外のパターンは編集不可
            default => false,
        };
        
        // または最適化して:
        return match(true) {
            $userRole === UserRole::ADMIN => true,
            $isOwner && in_array($this, [self::PENDING, self::PAID]) => true,
            default => false
        };
    }
}

リファクタリングのステップバイステップガイド

既存の複雑な条件分岐をEnumに移行する手順:

  1. 関連する条件をグループ化
    • 条件の意味や目的に基づいてグループを特定
  2. Enumの定義
    • 各グループに対応するEnumクラスを設計
    • 適切な型(Pure/Backed)を選択
  3. 入力値からEnumへの変換ロジック実装
    • from/tryFromメソッドやカスタムファクトリメソッドを実装
  4. 条件ロジックのEnumメソッドへの移行
    • match式を使用して既存の条件分岐をリファクタリング
    • 条件の組み合わせを効率的に表現
  5. メイン処理の書き換え
    • Enumベースの新しいロジックを使うようにメイン処理を更新
    • 型安全な引数と戻り値を設計

Enumを使用した条件分岐のリファクタリングは、コードの質を大幅に向上させる効果的な手法です。特に複雑なビジネスルールが関わる場合、Enumによる明示的な表現と型安全性は、バグの削減とコードのメンテナンス性向上に大きく貢献します。

実践例4:値オブジェクトとドメイン駆動設計

ドメイン駆動設計(DDD)は、複雑なビジネスドメインを効果的にモデリングするための方法論です。PHP Enumは、DDDの中心的な概念である「値オブジェクト」を実装する強力な手段となります。

値オブジェクトとは

値オブジェクトは、DDDにおいて以下の特性を持つオブジェクトです:

  1. 同一性がない – 内容が同じであれば等価とみなされる
  2. 不変性 – 作成後に内容が変更されない
  3. 自己完結性 – ドメインの概念を完全に表現する
  4. 副作用がない – 操作が状態を変更しない

PHPのEnumは、その性質上これらの特性を自然に満たし、値オブジェクトの実装に理想的です。

Enumを値オブジェクトとして活用する

Enumは単純な列挙型以上の役割を果たします。ドメイン概念をカプセル化し、関連するビジネスルールを集約できます:

// 通貨を表す値オブジェクトとしてのEnum
enum Currency: string
{
    case JPY = 'JPY';
    case USD = 'USD';
    case EUR = 'EUR';
    case GBP = 'GBP';
    
    // 通貨記号を取得
    public function getSymbol(): string
    {
        return match($this) {
            self::JPY => '¥',
            self::USD => '$',
            self::EUR => '€',
            self::GBP => '£',
        };
    }
    
    // 小数点以下の桁数を取得(通貨ごとの表示ルール)
    public function getDecimalPlaces(): int
    {
        return match($this) {
            self::JPY => 0,   // 日本円は小数点以下なし
            default => 2,     // その他の通貨は2桁
        };
    }
    
    // 金額のフォーマット方法
    public function format(float $amount): string
    {
        $decimals = $this->getDecimalPlaces();
        $formattedAmount = number_format($amount, $decimals);
        
        return match($this) {
            self::JPY, self::USD => $this->getSymbol() . $formattedAmount,
            self::EUR, self::GBP => $formattedAmount . $this->getSymbol(),
        };
    }
}

// Money値オブジェクトとEnumの組み合わせ
class Money
{
    private float $amount;
    private Currency $currency;
    
    // 不変性を保証するコンストラクタ
    public function __construct(float $amount, Currency $currency)
    {
        $this->amount = $amount;
        $this->currency = $currency;
    }
    
    // 等価性の実装
    public function equals(self $other): bool
    {
        return $this->amount === $other->amount && 
               $this->currency === $other->currency;
    }
    
    // 加算操作(新しいインスタンスを返す)
    public function add(self $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new \InvalidArgumentException('通貨単位が異なるMoney同士は計算できません');
        }
        
        return new self($this->amount + $other->amount, $this->currency);
    }
    
    // 乗算操作(新しいインスタンスを返す)
    public function multiply(float $multiplier): self
    {
        return new self($this->amount * $multiplier, $this->currency);
    }
    
    // 表示用のフォーマット
    public function format(): string
    {
        return $this->currency->format($this->amount);
    }
    
    // ゲッター
    public function getAmount(): float
    {
        return $this->amount;
    }
    
    public function getCurrency(): Currency
    {
        return $this->currency;
    }
}

// 使用例
$jpy1000 = new Money(1000, Currency::JPY);
$jpy2000 = new Money(2000, Currency::JPY);
$sum = $jpy1000->add($jpy2000);

echo $sum->format();  // "¥3,000"

$usd100 = new Money(100, Currency::USD);
echo $usd100->format();  // "$100.00"

このコードは、通貨という概念をEnumで表現し、それをMoney値オブジェクトと組み合わせることで、金額計算と表示に関するドメインルールをカプセル化しています。

より複雑なドメイン概念の表現

Enumを使用してより複雑なドメイン概念を表現することも可能です:

// 商品の評価を表すEnum
enum ProductRating: int
{
    case ONE_STAR = 1;
    case TWO_STARS = 2;
    case THREE_STARS = 3;
    case FOUR_STARS = 4;
    case FIVE_STARS = 5;
    
    // 星評価の文字表現
    public function getStars(): string
    {
        return str_repeat('★', $this->value);
    }
    
    // 評価カテゴリ
    public function getCategory(): string
    {
        return match($this) {
            self::ONE_STAR, self::TWO_STARS => '低評価',
            self::THREE_STARS => '普通',
            self::FOUR_STARS, self::FIVE_STARS => '高評価',
        };
    }
    
    // 推奨レベル
    public function isRecommended(): bool
    {
        return $this->value >= 4;
    }
    
    // 文字列からの変換(バリデーション付き)
    public static function fromString(string $rating): self
    {
        $intRating = (int) $rating;
        
        if ($intRating < 1 || $intRating > 5) {
            throw new \InvalidArgumentException('評価は1から5の間でなければなりません');
        }
        
        return self::from($intRating);
    }
}

// レビュー値オブジェクト
class Review
{
    private ProductRating $rating;
    private ?string $comment;
    private \DateTimeImmutable $createdAt;
    
    public function __construct(ProductRating $rating, ?string $comment = null)
    {
        $this->rating = $rating;
        $this->comment = $comment;
        $this->createdAt = new \DateTimeImmutable();
    }
    
    public function getRating(): ProductRating
    {
        return $this->rating;
    }
    
    public function getComment(): ?string
    {
        return $this->comment;
    }
    
    public function getCreatedAt(): \DateTimeImmutable
    {
        return $this->createdAt;
    }
    
    // レビューの表示形式
    public function getFormattedRating(): string
    {
        return $this->rating->getStars() . ' (' . $this->rating->value . '/5)';
    }
    
    // 高評価レビューかどうか
    public function isPositive(): bool
    {
        return $this->rating->isRecommended();
    }
}

// 使用例
$fiveStarReview = new Review(ProductRating::FIVE_STARS, '素晴らしい商品です!');
echo $fiveStarReview->getFormattedRating();  // "★★★★★ (5/5)"

if ($fiveStarReview->isPositive()) {
    echo "これは推奨レビューです";
}

このProductRatingのEnumは、単なる数値ではなく、評価という豊かなドメイン概念を表現しています。

DDDにおけるドメインルールのカプセル化

Enumを値オブジェクトとして使用する最大の利点は、ドメインルールをカプセル化できることです:

// 注文ステータスを表すEnum
enum OrderStatus: string
{
    case DRAFT = 'draft';
    case PENDING = 'pending';
    case PROCESSING = 'processing';
    case SHIPPED = 'shipped';
    case DELIVERED = 'delivered';
    case CANCELLED = 'cancelled';
    
    // ステータスに応じた次のアクション名を取得
    public function getNextActionName(): string
    {
        return match($this) {
            self::DRAFT => '注文確定',
            self::PENDING => '処理開始',
            self::PROCESSING => '出荷手続き',
            self::SHIPPED => '配達完了確認',
            self::DELIVERED => 'レビュー依頼',
            self::CANCELLED => '再注文',
        };
    }
    
    // 注文がキャンセル可能かどうか
    public function isCancellable(): bool
    {
        return in_array($this, [self::DRAFT, self::PENDING, self::PROCESSING]);
    }
    
    // 注文が変更可能かどうか
    public function isModifiable(): bool
    {
        return in_array($this, [self::DRAFT, self::PENDING]);
    }
    
    // 顧客が追跡可能かどうか
    public function isTrackable(): bool
    {
        return in_array($this, [self::SHIPPED, self::DELIVERED]);
    }
    
    // このステータスで可能な操作のリストを取得
    public function getAllowedOperations(): array
    {
        $operations = [];
        
        if ($this->isCancellable()) {
            $operations[] = 'cancel';
        }
        
        if ($this->isModifiable()) {
            $operations[] = 'modify';
        }
        
        if ($this->isTrackable()) {
            $operations[] = 'track';
        }
        
        return match($this) {
            self::DRAFT => array_merge($operations, ['confirm']),
            self::PENDING => array_merge($operations, ['process']),
            self::PROCESSING => array_merge($operations, ['ship']),
            self::SHIPPED => array_merge($operations, ['deliver']),
            self::DELIVERED => array_merge($operations, ['review']),
            self::CANCELLED => array_merge($operations, ['reorder']),
        };
    }
}

// 注文エンティティでの使用例
class Order
{
    private int $id;
    private OrderStatus $status;
    private Customer $customer;
    private array $items = [];
    
    // ゲッター・セッター省略
    
    // 注文のキャンセル操作
    public function cancel(): void
    {
        if (!$this->status->isCancellable()) {
            throw new \DomainException('この注文はキャンセルできません');
        }
        
        $this->status = OrderStatus::CANCELLED;
        // キャンセル処理の実装...
    }
    
    // 操作が許可されているかチェック
    public function canPerform(string $operation): bool
    {
        return in_array($operation, $this->status->getAllowedOperations());
    }
    
    // 次のステータスへ進める
    public function proceedToNextStatus(): void
    {
        $this->status = match($this->status) {
            OrderStatus::DRAFT => OrderStatus::PENDING,
            OrderStatus::PENDING => OrderStatus::PROCESSING,
            OrderStatus::PROCESSING => OrderStatus::SHIPPED,
            OrderStatus::SHIPPED => OrderStatus::DELIVERED,
            default => throw new \DomainException('この注文はこれ以上進めません')
        };
    }
}

このOrderStatusのEnumは、注文のライフサイクル全体に関するルールを集約し、Orderエンティティに明確なインターフェースを提供しています。

値オブジェクトとして使用する際のベストプラクティス

  1. ファクトリーメソッドの提供
    • 文字列や数値からの安全な変換を行うstaticメソッド
    • バリデーションルールを組み込む
  2. ドメインルールのカプセル化
    • ビジネスロジックをEnumのメソッドに集約
    • データと振る舞いを結合
  3. リッチな比較操作
    • 単純な等価比較だけでなく、意味のある比較メソッド
    • isGreaterThan(), isBetterThan() などの表現力豊かなメソッド
  4. 不変性の活用
    • Enumの不変性を前提とした設計
    • 変更が必要な場合は新しいインスタンスを生成
  5. 型安全性の徹底
    • プリミティブ型(文字列や整数)ではなくEnum型を引数に要求
    • 型宣言により不正な値の混入を防止

PHP Enumを値オブジェクトとして活用することで、ドメインの概念をコードとして明確に表現し、ビジネスルールをカプセル化した強力なドメインモデルを構築できます。これにより、アプリケーションの保守性と拡張性が大きく向上します。

Enumを活用した値オブジェクトの実装例

値オブジェクトは、ドメイン駆動設計(DDD)の重要な構成要素で、概念的には「属性しか持たないオブジェクト」です。PHPのEnumを活用することで、より表現力豊かで型安全な値オブジェクトを実装できます。

メールアドレス値オブジェクト

メールアドレスを単なる文字列ではなく、値オブジェクトとして実装する例を見てみましょう:

// メールアドレスのドメイン種別をEnumで表現
enum EmailDomainType
{
    case CORPORATE;   // 企業ドメイン
    case PERSONAL;    // 個人用メールサービス
    case ACADEMIC;    // 教育機関
    case GOVERNMENT;  // 政府機関
    case OTHER;       // その他
    
    // ドメインの種類を判定
    public static function fromDomain(string $domain): self
    {
        // 企業メール
        if (preg_match('/\.(co\.jp|com|co|corp|inc)$/', $domain)) {
            return self::CORPORATE;
        }
        
        // 個人用メールサービス
        if (in_array($domain, ['gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com'])) {
            return self::PERSONAL;
        }
        
        // 学術機関
        if (preg_match('/\.(edu|ac\.jp|\.edu\.)/', $domain)) {
            return self::ACADEMIC;
        }
        
        // 政府機関
        if (preg_match('/\.(gov|go\.jp)$/', $domain)) {
            return self::GOVERNMENT;
        }
        
        return self::OTHER;
    }
    
    // ドメイン種別の説明
    public function getDescription(): string
    {
        return match($this) {
            self::CORPORATE => '企業ドメイン',
            self::PERSONAL => '個人用メールサービス',
            self::ACADEMIC => '教育機関',
            self::GOVERNMENT => '政府機関',
            self::OTHER => 'その他のドメイン'
        };
    }
    
    // 特定の種類のメールアドレスかをチェック
    public function isPersonal(): bool
    {
        return $this === self::PERSONAL;
    }
    
    public function isCorporate(): bool
    {
        return $this === self::CORPORATE;
    }
}

// Email値オブジェクト
class Email
{
    private string $address;
    private string $localPart;
    private string $domain;
    private EmailDomainType $domainType;
    
    // 不変性を確保するプライベートコンストラクタ
    private function __construct(string $address)
    {
        $this->address = $address;
        
        // ローカル部とドメイン部に分割
        list($this->localPart, $this->domain) = explode('@', $address, 2);
        
        // ドメイン種別を判定
        $this->domainType = EmailDomainType::fromDomain($this->domain);
    }
    
    // ファクトリーメソッド(バリデーション付き)
    public static function fromString(string $address): self
    {
        // メールアドレスの形式をバリデーション
        if (!filter_var($address, FILTER_VALIDATE_EMAIL)) {
            throw new \InvalidArgumentException('不正なメールアドレス形式です: ' . $address);
        }
        
        return new self($address);
    }
    
    // 安全な作成メソッド(無効なら例外ではなくnullを返す)
    public static function tryFromString(string $address): ?self
    {
        try {
            return self::fromString($address);
        } catch (\InvalidArgumentException $e) {
            return null;
        }
    }
    
    // アドレス文字列を取得
    public function toString(): string
    {
        return $this->address;
    }
    
    // ローカル部を取得
    public function getLocalPart(): string
    {
        return $this->localPart;
    }
    
    // ドメイン部を取得
    public function getDomain(): string
    {
        return $this->domain;
    }
    
    // ドメイン種別を取得
    public function getDomainType(): EmailDomainType
    {
        return $this->domainType;
    }
    
    // 等価性の実装
    public function equals(self $other): bool
    {
        return $this->address === $other->address;
    }
    
    // ドメイン種別に基づく判定メソッド
    public function isPersonalEmail(): bool
    {
        return $this->domainType->isPersonal();
    }
    
    public function isCorporateEmail(): bool
    {
        return $this->domainType->isCorporate();
    }
    
    // ドメイン種別に基づく説明
    public function getDomainDescription(): string
    {
        return $this->domainType->getDescription();
    }
    
    // 文字列キャスト
    public function __toString(): string
    {
        return $this->address;
    }
}

// 使用例
try {
    $email = Email::fromString('john.doe@example.com');
    
    echo $email->getDomain();  // "example.com"
    echo $email->getDomainDescription();  // "企業ドメイン"
    
    if ($email->isCorporateEmail()) {
        echo "これは企業のメールアドレスです";
    }
} catch (\InvalidArgumentException $e) {
    echo $e->getMessage();
}

このEmail値オブジェクトは、EmailDomainTypeというEnumと組み合わせることで、単なる文字列検証を超えて、ドメインの種類に基づいた判断ができるようになっています。

電話番号値オブジェクト

電話番号も、Enumを活用した値オブジェクトとして実装できます:

// 国コードをEnumで表現
enum CountryCode: string
{
    case JAPAN = 'JP';
    case USA = 'US';
    case UK = 'GB';
    case CHINA = 'CN';
    case KOREA = 'KR';
    
    // 国際電話番号のプレフィックスを取得
    public function getPrefix(): string
    {
        return match($this) {
            self::JAPAN => '+81',
            self::USA => '+1',
            self::UK => '+44',
            self::CHINA => '+86',
            self::KOREA => '+82',
        };
    }
    
    // 電話番号のフォーマットパターンを取得
    public function getNumberPattern(): string
    {
        return match($this) {
            self::JAPAN => '/^0\d{1,4}-\d{1,4}-\d{4}$/',   // 日本の形式
            self::USA => '/^\(\d{3}\) \d{3}-\d{4}$/',      // 米国の形式
            default => '/^\+?[\d\s-]{8,15}$/',             // 汎用パターン
        };
    }
    
    // 表示用の国名を取得
    public function getCountryName(): string
    {
        return match($this) {
            self::JAPAN => '日本',
            self::USA => 'アメリカ',
            self::UK => 'イギリス',
            self::CHINA => '中国',
            self::KOREA => '韓国',
        };
    }
}

// 電話番号値オブジェクト
class PhoneNumber
{
    private string $number;
    private CountryCode $countryCode;
    
    private function __construct(string $number, CountryCode $countryCode)
    {
        $this->number = $number;
        $this->countryCode = $countryCode;
    }
    
    // 日本の電話番号を作成
    public static function fromJapaneseNumber(string $number): self
    {
        $pattern = CountryCode::JAPAN->getNumberPattern();
        
        if (!preg_match($pattern, $number)) {
            throw new \InvalidArgumentException('不正な日本の電話番号形式です: ' . $number);
        }
        
        return new self($number, CountryCode::JAPAN);
    }
    
    // 米国の電話番号を作成
    public static function fromUSNumber(string $number): self
    {
        $pattern = CountryCode::USA->getNumberPattern();
        
        if (!preg_match($pattern, $number)) {
            throw new \InvalidArgumentException('不正な米国の電話番号形式です: ' . $number);
        }
        
        return new self($number, CountryCode::USA);
    }
    
    // 国コードを指定して作成(汎用)
    public static function fromNumberWithCountry(string $number, CountryCode $countryCode): self
    {
        // 各国のフォーマットチェックは省略
        return new self($number, $countryCode);
    }
    
    // 国際形式で取得
    public function toInternational(): string
    {
        $prefix = $this->countryCode->getPrefix();
        
        // 国ごとの変換ロジック(簡略化)
        $internationalNumber = match($this->countryCode) {
            CountryCode::JAPAN => preg_replace('/^0/', '', str_replace('-', '', $this->number)),
            CountryCode::USA => preg_replace('/^\((\d{3})\) (\d{3})-(\d{4})$/', '$1$2$3', $this->number),
            default => str_replace(['-', ' '], '', $this->number),
        };
        
        return $prefix . $internationalNumber;
    }
    
    // 国内形式で取得
    public function toDomestic(): string
    {
        return $this->number;
    }
    
    // 国コードを取得
    public function getCountryCode(): CountryCode
    {
        return $this->countryCode;
    }
    
    // 国名を取得
    public function getCountryName(): string
    {
        return $this->countryCode->getCountryName();
    }
    
    // 等価性の実装
    public function equals(self $other): bool
    {
        // 国際形式で比較することで異なる形式でも同じ番号と判定
        return $this->toInternational() === $other->toInternational();
    }
}

// 使用例
$jpPhone = PhoneNumber::fromJapaneseNumber('03-1234-5678');
echo $jpPhone->toInternational();  // "+81312345678"
echo $jpPhone->getCountryName();   // "日本"

$usPhone = PhoneNumber::fromUSNumber('(123) 456-7890');
echo $usPhone->toInternational();  // "+11234567890"

// 同じ電話番号かどうかの比較
if ($jpPhone->equals($usPhone)) {
    echo "同じ電話番号です";
} else {
    echo "異なる電話番号です";
}

ISBNなどの標準化された識別子

ISBN(国際標準図書番号)のような標準化された識別子も、Enumを活用した値オブジェクトとして実装できます:

// ISBN形式をEnumで表現
enum ISBNFormat
{
    case ISBN10;    // 10桁形式
    case ISBN13;    // 13桁形式
    
    // バリデーションパターンを取得
    public function getValidationPattern(): string
    {
        return match($this) {
            self::ISBN10 => '/^[0-9]{9}[0-9X]$/',
            self::ISBN13 => '/^97[89][0-9]{10}$/',
        };
    }
    
    // 表示用のラベルを取得
    public function getLabel(): string
    {
        return match($this) {
            self::ISBN10 => 'ISBN-10',
            self::ISBN13 => 'ISBN-13',
        };
    }
    
    // チェックディジットが有効かを検証
    public function validateCheckDigit(string $isbn): bool
    {
        return match($this) {
            self::ISBN10 => self::validateISBN10CheckDigit($isbn),
            self::ISBN13 => self::validateISBN13CheckDigit($isbn),
        };
    }
    
    // ISBN-10のチェックディジット検証
    private static function validateISBN10CheckDigit(string $isbn): bool
    {
        $sum = 0;
        $length = strlen($isbn);
        
        for ($i = 0; $i < $length - 1; $i++) {
            $sum += (int)$isbn[$i] * (10 - $i);
        }
        
        $checkDigit = $isbn[$length - 1];
        $checkDigit = ($checkDigit === 'X') ? 10 : (int)$checkDigit;
        
        $remainder = $sum % 11;
        $calculatedCheck = (11 - $remainder) % 11;
        
        return $calculatedCheck === $checkDigit;
    }
    
    // ISBN-13のチェックディジット検証
    private static function validateISBN13CheckDigit(string $isbn): bool
    {
        $sum = 0;
        $length = strlen($isbn);
        
        for ($i = 0; $i < $length - 1; $i++) {
            $weight = ($i % 2 === 0) ? 1 : 3;
            $sum += (int)$isbn[$i] * $weight;
        }
        
        $remainder = $sum % 10;
        $calculatedCheck = (10 - $remainder) % 10;
        
        return $calculatedCheck === (int)$isbn[$length - 1];
    }
}

// ISBN値オブジェクト
class ISBN
{
    private string $value;
    private ISBNFormat $format;
    
    private function __construct(string $value, ISBNFormat $format)
    {
        $this->value = $value;
        $this->format = $format;
    }
    
    // 文字列からISBNを作成
    public static function fromString(string $isbn): self
    {
        // ハイフンや空白を削除して正規化
        $normalized = preg_replace('/[- ]/', '', $isbn);
        
        // フォーマットを判定
        $format = (strlen($normalized) === 10) 
            ? ISBNFormat::ISBN10 
            : ((strlen($normalized) === 13) ? ISBNFormat::ISBN13 : null);
        
        if ($format === null) {
            throw new \InvalidArgumentException('無効なISBN形式です。10桁または13桁である必要があります。');
        }
        
        // パターンチェック
        if (!preg_match($format->getValidationPattern(), $normalized)) {
            throw new \InvalidArgumentException('無効なISBN形式です: ' . $isbn);
        }
        
        // チェックディジットの検証
        if (!$format->validateCheckDigit($normalized)) {
            throw new \InvalidArgumentException('無効なISBNチェックディジットです: ' . $isbn);
        }
        
        return new self($normalized, $format);
    }
    
    // 10桁形式から13桁形式に変換
    public function toISBN13(): self
    {
        if ($this->format === ISBNFormat::ISBN13) {
            return $this;
        }
        
        // 10桁から13桁への変換ロジック(簡略化)
        $isbn13 = '978' . substr($this->value, 0, 9);
        
        // チェックディジットの計算
        $sum = 0;
        for ($i = 0; $i < 12; $i++) {
            $weight = ($i % 2 === 0) ? 1 : 3;
            $sum += (int)$isbn13[$i] * $weight;
        }
        
        $remainder = $sum % 10;
        $checkDigit = (10 - $remainder) % 10;
        
        $isbn13 .= $checkDigit;
        
        return new self($isbn13, ISBNFormat::ISBN13);
    }
    
    // フォーマット付きで表示
    public function format(string $separator = '-'): string
    {
        if ($this->format === ISBNFormat::ISBN10) {
            return sprintf(
                '%s%s%s%s%s',
                substr($this->value, 0, 1),
                $separator,
                substr($this->value, 1, 3),
                $separator,
                substr($this->value, 4, 5),
                $separator,
                substr($this->value, 9, 1)
            );
        } else {
            return sprintf(
                '%s%s%s%s%s%s%s',
                substr($this->value, 0, 3),
                $separator,
                substr($this->value, 3, 1),
                $separator,
                substr($this->value, 4, 6),
                $separator,
                substr($this->value, 10, 3)
            );
        }
    }
    
    // 値を取得
    public function getValue(): string
    {
        return $this->value;
    }
    
    // フォーマットを取得
    public function getFormat(): ISBNFormat
    {
        return $this->format;
    }
    
    // フォーマットラベルを取得
    public function getFormatLabel(): string
    {
        return $this->format->getLabel();
    }
    
    // 10桁形式かどうか
    public function isISBN10(): bool
    {
        return $this->format === ISBNFormat::ISBN10;
    }
    
    // 13桁形式かどうか
    public function isISBN13(): bool
    {
        return $this->format === ISBNFormat::ISBN13;
    }
    
    // 等価性の実装
    public function equals(self $other): bool
    {
        // 13桁形式に揃えて比較
        $this13 = $this->isISBN13() ? $this : $this->toISBN13();
        $other13 = $other->isISBN13() ? $other : $other->toISBN13();
        
        return $this13->value === $other13->value;
    }
    
    // 文字列キャスト
    public function __toString(): string
    {
        return $this->format->getLabel() . ': ' . $this->format();
    }
}

// 使用例
try {
    $isbn10 = ISBN::fromString('0-306-40615-2');
    echo $isbn10;  // "ISBN-10: 0-306-40615-2"
    
    $isbn13 = $isbn10->toISBN13();
    echo $isbn13;  // "ISBN-13: 978-0-306-40615-7"
    
    if ($isbn10->equals($isbn13)) {
        echo "同じ書籍を参照しています";
    }
} catch (\InvalidArgumentException $e) {
    echo $e->getMessage();
}

値オブジェクトとEnumの連携パターン

上記の例から、値オブジェクトとEnum型を連携させる主なパターンが見えてきます:

  1. Enum型で分類や種別を表現
    • EmailのドメインタイプやISBNのフォーマットなど
    • 有限かつ明確に区別できる種別を表現
  2. Enumに振る舞いを持たせる
    • 各種別に固有のロジックをEnumメソッドとして実装
    • 例:フォーマットパターンの提供、バリデーションルールなど
  3. 値オブジェクトがEnumを内部的に使用
    • 値オブジェクトがEnumを状態として保持
    • Enumの振る舞いを委譲して活用
  4. ファクトリーメソッドで安全な生成
    • Enumの静的メソッドや値オブジェクトのファクトリーメソッドによる安全な生成
    • バリデーションロジックの集約

このようなパターンを活用することで、プリミティブな型(文字列や数値)ではなく、ドメイン固有の概念を型として表現し、ビジネスルールをコードに組み込むことができます。

値オブジェクトとEnumを組み合わせることで、型安全性、ドメイン知識の表現力、コードの可読性が大きく向上します。特に、DDDアプローチを採用するプロジェクトでは、このパターンが非常に有効です。

ドメインルールをEnumに閉じ込める設計パターン

ドメインルールとは、ビジネス領域固有の制約や振る舞いを定義するルールのことです。これらのルールをコードで表現する場合、PHPのEnumを活用することで、より明確で一貫性のある実装が可能になります。

ドメインルールとEnum

ドメインルールをEnumに閉じ込めることの主なメリットは以下の通りです:

  1. 集中管理 – 関連するルールが一箇所にまとまり、散在を防ぐ
  2. 自己文書化 – ルールの意図がコードとして明示される
  3. 型安全性 – コンパイル時チェックにより不正な値の混入を防ぐ
  4. テスト容易性 – ルールを独立してテストできる
  5. 変更の局所化 – ルール変更の影響範囲を最小限に抑えられる

実装パターン1: 割引ルールの表現

Eコマースサイトの割引ルールをEnumで表現する例:

// 割引タイプをEnumで表現
enum DiscountType
{
    case PERCENT;        // 割合割引
    case FIXED_AMOUNT;   // 固定金額割引
    case BUY_X_GET_Y;    // X個買うとY個無料
    case BUNDLE;         // セット割引
    
    // 割引計算ロジックをEnumに閉じ込める
    public function calculateDiscount(float $originalPrice, array $parameters): float
    {
        return match($this) {
            self::PERCENT => $this->calculatePercentDiscount($originalPrice, $parameters),
            self::FIXED_AMOUNT => $this->calculateFixedDiscount($originalPrice, $parameters),
            self::BUY_X_GET_Y => $this->calculateBuyXGetYDiscount($originalPrice, $parameters),
            self::BUNDLE => $this->calculateBundleDiscount($originalPrice, $parameters),
        };
    }
    
    // 割合割引の計算
    private function calculatePercentDiscount(float $price, array $parameters): float
    {
        $percent = $parameters['percent'] ?? 0;
        $maxDiscount = $parameters['max_discount'] ?? PHP_FLOAT_MAX;
        
        $discount = $price * ($percent / 100);
        return min($discount, $maxDiscount);
    }
    
    // 固定金額割引の計算
    private function calculateFixedDiscount(float $price, array $parameters): float
    {
        $amount = $parameters['amount'] ?? 0;
        return min($amount, $price); // 商品価格を超える割引はできない
    }
    
    // X個買うとY個無料の割引計算
    private function calculateBuyXGetYDiscount(float $price, array $parameters): float
    {
        $quantity = $parameters['quantity'] ?? 1;
        $buyCount = $parameters['buy_count'] ?? 1;
        $getFreeCount = $parameters['get_free_count'] ?? 0;
        
        if ($quantity < $buyCount) {
            return 0; // 条件を満たさない場合は割引なし
        }
        
        $sets = floor($quantity / ($buyCount + $getFreeCount));
        $freeItems = min($sets * $getFreeCount, $quantity - $buyCount);
        
        $unitPrice = $price / $quantity;
        return $freeItems * $unitPrice;
    }
    
    // セット割引の計算
    private function calculateBundleDiscount(float $price, array $parameters): float
    {
        $bundleDiscount = $parameters['bundle_discount'] ?? 0;
        $requiredCategories = $parameters['required_categories'] ?? [];
        $presentCategories = $parameters['present_categories'] ?? [];
        
        // すべての必要カテゴリが揃っているか確認
        $allCategoriesPresent = empty(array_diff($requiredCategories, $presentCategories));
        
        return $allCategoriesPresent ? $bundleDiscount : 0;
    }
    
    // 割引の説明を取得
    public function getDescription(array $parameters): string
    {
        return match($this) {
            self::PERCENT => $parameters['percent'] . '%割引',
            self::FIXED_AMOUNT => number_format($parameters['amount']) . '円割引',
            self::BUY_X_GET_Y => $parameters['buy_count'] . '個買うと' . $parameters['get_free_count'] . '個無料',
            self::BUNDLE => 'セット割引',
        };
    }
    
    // 割引の適用条件をチェック
    public function isApplicable(array $orderData, array $parameters): bool
    {
        return match($this) {
            self::PERCENT, self::FIXED_AMOUNT => true, // 基本的に常に適用可能
            self::BUY_X_GET_Y => $orderData['quantity'] >= ($parameters['buy_count'] ?? 1),
            self::BUNDLE => $this->checkBundleRequirements($orderData, $parameters),
        };
    }
    
    // セット割引の条件チェック
    private function checkBundleRequirements(array $orderData, array $parameters): bool
    {
        $requiredCategories = $parameters['required_categories'] ?? [];
        $orderCategories = $orderData['categories'] ?? [];
        
        return empty(array_diff($requiredCategories, $orderCategories));
    }
}

// 割引ルールのコンテキストクラス
class Discount
{
    private DiscountType $type;
    private array $parameters;
    
    public function __construct(DiscountType $type, array $parameters)
    {
        $this->type = $type;
        $this->parameters = $parameters;
    }
    
    // 割引金額を計算
    public function apply(float $price, array $orderData = []): float
    {
        if (!$this->isApplicable($orderData)) {
            return 0;
        }
        
        return $this->type->calculateDiscount($price, $this->parameters);
    }
    
    // 適用条件をチェック
    public function isApplicable(array $orderData): bool
    {
        return $this->type->isApplicable($orderData, $this->parameters);
    }
    
    // 割引の説明を取得
    public function getDescription(): string
    {
        return $this->type->getDescription($this->parameters);
    }
}

// 使用例
// 20%割引(最大2000円まで)を作成
$percentDiscount = new Discount(
    DiscountType::PERCENT,
    ['percent' => 20, 'max_discount' => 2000]
);

// 3個買うと1個無料の割引
$buyXGetYDiscount = new Discount(
    DiscountType::BUY_X_GET_Y,
    ['buy_count' => 3, 'get_free_count' => 1]
);

// 割引の適用
$orderData = ['quantity' => 4, 'categories' => ['electronics', 'accessories']];
$originalPrice = 10000;

$discount1 = $percentDiscount->apply($originalPrice, $orderData);
$discount2 = $buyXGetYDiscount->apply($originalPrice, $orderData);

echo "割引1: " . $percentDiscount->getDescription() . " - " . $discount1 . "円\n";
echo "割引2: " . $buyXGetYDiscount->getDescription() . " - " . $discount2 . "円\n";

このパターンでは、DiscountTypeというEnumに割引計算ロジックとルールを閉じ込めています。これにより、新しい割引タイプを追加する際も、Enumに新しいケースとメソッドを追加するだけで済みます。

実装パターン2: 資格判定ルール

ユーザーの資格や権限判定ルールをEnumに閉じ込める例:

// 会員資格レベルをEnumで表現
enum MembershipTier
{
    case BRONZE;
    case SILVER;
    case GOLD;
    case PLATINUM;
    
    // 必要なポイント数を取得
    public function getRequiredPoints(): int
    {
        return match($this) {
            self::BRONZE => 0,
            self::SILVER => 100,
            self::GOLD => 500,
            self::PLATINUM => 1000,
        };
    }
    
    // 所定のポイントでのランクを取得
    public static function fromPoints(int $points): self
    {
        if ($points >= self::PLATINUM->getRequiredPoints()) {
            return self::PLATINUM;
        } elseif ($points >= self::GOLD->getRequiredPoints()) {
            return self::GOLD;
        } elseif ($points >= self::SILVER->getRequiredPoints()) {
            return self::SILVER;
        } else {
            return self::BRONZE;
        }
    }
    
    // ランクアップに必要な残りポイントを計算
    public function pointsToNextTier(int $currentPoints): ?int
    {
        $nextTier = $this->getNextTier();
        if ($nextTier === null) {
            return null; // 最高ランクの場合はnull
        }
        
        return max(0, $nextTier->getRequiredPoints() - $currentPoints);
    }
    
    // 次のランクを取得
    public function getNextTier(): ?self
    {
        return match($this) {
            self::BRONZE => self::SILVER,
            self::SILVER => self::GOLD,
            self::GOLD => self::PLATINUM,
            self::PLATINUM => null, // 最高ランク
        };
    }
    
    // 特典リストを取得
    public function getBenefits(): array
    {
        $baseBenefits = ['基本サポート'];
        
        return match($this) {
            self::BRONZE => $baseBenefits,
            self::SILVER => [...$baseBenefits, '5%割引', '優先カスタマーサポート'],
            self::GOLD => [...self::SILVER->getBenefits(), '誕生日ギフト', '限定イベント'],
            self::PLATINUM => [...self::GOLD->getBenefits(), 'コンシェルジュサービス', 'VIPイベント', '無料配送'],
        };
    }
    
    // ポイント獲得倍率を取得
    public function getPointMultiplier(): float
    {
        return match($this) {
            self::BRONZE => 1.0,
            self::SILVER => 1.2,
            self::GOLD => 1.5,
            self::PLATINUM => 2.0,
        };
    }
    
    // 表示名を取得
    public function getDisplayName(): string
    {
        return match($this) {
            self::BRONZE => 'ブロンズ会員',
            self::SILVER => 'シルバー会員',
            self::GOLD => 'ゴールド会員',
            self::PLATINUM => 'プラチナ会員',
        };
    }
}

// 会員クラス
class Member
{
    private string $name;
    private int $points;
    private MembershipTier $tier;
    
    public function __construct(string $name, int $points)
    {
        $this->name = $name;
        $this->points = $points;
        $this->tier = MembershipTier::fromPoints($points);
    }
    
    // ポイント追加
    public function addPoints(int $amount): void
    {
        $multiplier = $this->tier->getPointMultiplier();
        $this->points += (int)($amount * $multiplier);
        
        // ポイント更新後にランクを再計算
        $this->tier = MembershipTier::fromPoints($this->points);
    }
    
    // 現在のランクでの特典リスト
    public function getBenefits(): array
    {
        return $this->tier->getBenefits();
    }
    
    // 次のランクまでの残りポイント
    public function getPointsToNextTier(): ?int
    {
        return $this->tier->pointsToNextTier($this->points);
    }
    
    // 会員情報の表示
    public function getMemberInfo(): array
    {
        $nextTierPoints = $this->getPointsToNextTier();
        
        return [
            'name' => $this->name,
            'points' => $this->points,
            'tier' => $this->tier->getDisplayName(),
            'benefits' => $this->getBenefits(),
            'nextTier' => $this->tier->getNextTier()?->getDisplayName(),
            'pointsToNextTier' => $nextTierPoints,
        ];
    }
}

// 使用例
$member = new Member('山田太郎', 300);
$info = $member->getMemberInfo();

echo "会員名: {$info['name']}\n";
echo "会員ランク: {$info['tier']}\n";
echo "ポイント: {$info['points']}\n";
echo "特典: " . implode(', ', $info['benefits']) . "\n";

if ($info['nextTier']) {
    echo "次のランク {$info['nextTier']} まであと{$info['pointsToNextTier']}ポイント\n";
}

// ポイント加算
$member->addPoints(100);
$newInfo = $member->getMemberInfo();
echo "新しいポイント: {$newInfo['points']}\n";
echo "新しいランク: {$newInfo['tier']}\n";

このパターンでは、MembershipTierというEnumに会員資格に関するルールや振る舞いを閉じ込めています。これにより、会員資格のロジックが一箇所に集中し、変更が容易になります。

ドメインルールをEnumに閉じ込める際のベストプラクティス

  1. 一つのEnumに一つの概念を表現する
    • 単一責任の原則を守り、関連する概念のみをグループ化
  2. メソッド名は意図を明確に
    • ドメインの言葉を使い、ビジネスルールを自己文書化
  3. 静的メソッドとインスタンスメソッドを使い分ける
    • 変換や生成には静的メソッド
    • 状態に依存する振る舞いにはインスタンスメソッド
  4. ルールの例外にも対応
    • 特殊なケースも明示的に取り扱う
    • 未知の値に対する挙動を明確に定義
  5. テスト容易性を意識
    • 各ルールを独立してテストできるよう設計
    • 複雑なケースも個別にテスト可能に

Enumにドメインルールを閉じ込めることで、ルールの変更や拡張が容易になり、コードの保守性と可読性を大きく向上させることができます。DDDの考え方に沿ったこのパターンは、特に複雑なビジネスルールを持つシステムで効果を発揮します。

実践例5:APIレスポンスとエラーハンドリング

APIの設計において、一貫性のあるレスポンス形式とエラーハンドリングは非常に重要です。PHPのEnumを活用することで、明確でタイプセーフなAPIレスポンスとエラー管理が実現できます。

APIレスポンスの標準化

APIレスポンスは一貫性のある構造を持ち、クライアントが予測しやすい形式であるべきです。Enumを使って、このような標準化を実現できます:

// APIレスポンスステータスをEnumで表現
enum ApiResponseStatus: string
{
    case SUCCESS = 'success';
    case ERROR = 'error';
    case VALIDATION_ERROR = 'validation_error';
    case UNAUTHORIZED = 'unauthorized';
    case FORBIDDEN = 'forbidden';
    case NOT_FOUND = 'not_found';
    case INTERNAL_ERROR = 'internal_error';
    
    // HTTPステータスコードを取得
    public function getHttpCode(): int
    {
        return match($this) {
            self::SUCCESS => 200,
            self::ERROR => 400,
            self::VALIDATION_ERROR => 422,
            self::UNAUTHORIZED => 401,
            self::FORBIDDEN => 403,
            self::NOT_FOUND => 404,
            self::INTERNAL_ERROR => 500,
        };
    }
    
    // ステータスの説明
    public function getDescription(): string
    {
        return match($this) {
            self::SUCCESS => 'リクエストは正常に処理されました',
            self::ERROR => 'リクエストの処理中にエラーが発生しました',
            self::VALIDATION_ERROR => '入力データが無効です',
            self::UNAUTHORIZED => '認証が必要です',
            self::FORBIDDEN => 'アクセス権限がありません',
            self::NOT_FOUND => 'リクエストされたリソースが見つかりません',
            self::INTERNAL_ERROR => 'サーバー内部エラーが発生しました',
        };
    }
    
    // クライアントエラーかどうか
    public function isClientError(): bool
    {
        return in_array($this, [
            self::ERROR,
            self::VALIDATION_ERROR,
            self::UNAUTHORIZED,
            self::FORBIDDEN,
            self::NOT_FOUND
        ]);
    }
    
    // サーバーエラーかどうか
    public function isServerError(): bool
    {
        return $this === self::INTERNAL_ERROR;
    }
    
    // 成功かどうか
    public function isSuccess(): bool
    {
        return $this === self::SUCCESS;
    }
}

// APIレスポンスクラス
class ApiResponse
{
    private ApiResponseStatus $status;
    private mixed $data;
    private ?array $errors;
    private ?string $message;
    
    public function __construct(
        ApiResponseStatus $status,
        mixed $data = null,
        ?array $errors = null,
        ?string $message = null
    ) {
        $this->status = $status;
        $this->data = $data;
        $this->errors = $errors;
        $this->message = $message ?? $status->getDescription();
    }
    
    // 成功レスポンスを作成
    public static function success(mixed $data = null, ?string $message = null): self
    {
        return new self(ApiResponseStatus::SUCCESS, $data, null, $message);
    }
    
    // エラーレスポンスを作成
    public static function error(
        ApiResponseStatus $status = ApiResponseStatus::ERROR,
        ?array $errors = null,
        ?string $message = null
    ): self {
        return new self($status, null, $errors, $message);
    }
    
    // レスポンスを配列に変換
    public function toArray(): array
    {
        $response = [
            'status' => $this->status->value,
            'message' => $this->message,
        ];
        
        if ($this->data !== null) {
            $response['data'] = $this->data;
        }
        
        if ($this->errors !== null) {
            $response['errors'] = $this->errors;
        }
        
        return $response;
    }
    
    // JSONレスポンスを送信
    public function send(): void
    {
        http_response_code($this->status->getHttpCode());
        header('Content-Type: application/json');
        echo json_encode($this->toArray());
        exit;
    }
    
    // ゲッター
    public function getStatus(): ApiResponseStatus
    {
        return $this->status;
    }
    
    public function getData(): mixed
    {
        return $this->data;
    }
    
    public function getErrors(): ?array
    {
        return $this->errors;
    }
    
    public function getMessage(): string
    {
        return $this->message;
    }
}

エラーコード管理

さらに詳細なエラーコードをEnumで管理することで、クライアントがエラーを適切に処理できるようになります:

// アプリケーション固有のエラーコードをEnumで定義
enum AppErrorCode: string
{
    // 認証関連エラー
    case AUTH_INVALID_CREDENTIALS = 'auth.invalid_credentials';
    case AUTH_TOKEN_EXPIRED = 'auth.token_expired';
    case AUTH_INVALID_TOKEN = 'auth.invalid_token';
    
    // ユーザー関連エラー
    case USER_NOT_FOUND = 'user.not_found';
    case USER_EMAIL_DUPLICATE = 'user.email_duplicate';
    case USER_INSUFFICIENT_PERMISSION = 'user.insufficient_permission';
    
    // 入力検証エラー
    case VALIDATION_REQUIRED_FIELD = 'validation.required_field';
    case VALIDATION_INVALID_FORMAT = 'validation.invalid_format';
    case VALIDATION_INVALID_VALUE = 'validation.invalid_value';
    
    // リソース関連エラー
    case RESOURCE_NOT_FOUND = 'resource.not_found';
    case RESOURCE_ALREADY_EXISTS = 'resource.already_exists';
    case RESOURCE_LOCKED = 'resource.locked';
    
    // システムエラー
    case SYSTEM_DATABASE_ERROR = 'system.database_error';
    case SYSTEM_SERVICE_UNAVAILABLE = 'system.service_unavailable';
    case SYSTEM_UNEXPECTED_ERROR = 'system.unexpected_error';
    
    // エラーコードのカテゴリを取得
    public function getCategory(): string
    {
        return explode('.', $this->value)[0];
    }
    
    // HTTPステータスコードへのマッピング
    public function getHttpStatus(): ApiResponseStatus
    {
        return match($this->getCategory()) {
            'auth' => match($this) {
                self::AUTH_INVALID_CREDENTIALS => ApiResponseStatus::UNAUTHORIZED,
                self::AUTH_TOKEN_EXPIRED => ApiResponseStatus::UNAUTHORIZED,
                self::AUTH_INVALID_TOKEN => ApiResponseStatus::UNAUTHORIZED,
            },
            'user' => match($this) {
                self::USER_NOT_FOUND => ApiResponseStatus::NOT_FOUND,
                self::USER_EMAIL_DUPLICATE => ApiResponseStatus::VALIDATION_ERROR,
                self::USER_INSUFFICIENT_PERMISSION => ApiResponseStatus::FORBIDDEN,
            },
            'validation' => ApiResponseStatus::VALIDATION_ERROR,
            'resource' => match($this) {
                self::RESOURCE_NOT_FOUND => ApiResponseStatus::NOT_FOUND,
                self::RESOURCE_ALREADY_EXISTS => ApiResponseStatus::VALIDATION_ERROR,
                self::RESOURCE_LOCKED => ApiResponseStatus::FORBIDDEN,
            },
            'system' => ApiResponseStatus::INTERNAL_ERROR,
            default => ApiResponseStatus::ERROR,
        };
    }
    
    // エラーメッセージの取得(例として日本語)
    public function getJapaneseMessage(): string
    {
        return match($this) {
            self::AUTH_INVALID_CREDENTIALS => 'ユーザー名またはパスワードが正しくありません',
            self::AUTH_TOKEN_EXPIRED => '認証トークンの有効期限が切れています',
            self::AUTH_INVALID_TOKEN => '無効な認証トークンです',
            
            self::USER_NOT_FOUND => '指定されたユーザーが見つかりません',
            self::USER_EMAIL_DUPLICATE => 'このメールアドレスは既に使用されています',
            self::USER_INSUFFICIENT_PERMISSION => 'この操作を行う権限がありません',
            
            self::VALIDATION_REQUIRED_FIELD => '必須項目が入力されていません',
            self::VALIDATION_INVALID_FORMAT => '入力形式が正しくありません',
            self::VALIDATION_INVALID_VALUE => '無効な値が指定されました',
            
            self::RESOURCE_NOT_FOUND => 'リソースが見つかりません',
            self::RESOURCE_ALREADY_EXISTS => 'リソースは既に存在します',
            self::RESOURCE_LOCKED => 'リソースは現在ロックされています',
            
            self::SYSTEM_DATABASE_ERROR => 'データベースエラーが発生しました',
            self::SYSTEM_SERVICE_UNAVAILABLE => '現在サービスをご利用いただけません',
            self::SYSTEM_UNEXPECTED_ERROR => '予期しないエラーが発生しました',
        };
    }
    
    // エラーメッセージの取得(例として英語)
    public function getEnglishMessage(): string
    {
        return match($this) {
            self::AUTH_INVALID_CREDENTIALS => 'Invalid username or password',
            self::AUTH_TOKEN_EXPIRED => 'Authentication token has expired',
            self::AUTH_INVALID_TOKEN => 'Invalid authentication token',
            
            // 他の英語メッセージも同様に定義
            default => 'An error occurred',
        };
    }
    
    // ロケールに応じたメッセージを取得
    public function getLocalizedMessage(string $locale = 'ja'): string
    {
        return match($locale) {
            'ja' => $this->getJapaneseMessage(),
            'en' => $this->getEnglishMessage(),
            default => $this->getJapaneseMessage(), // デフォルトは日本語
        };
    }
}

// API例外クラス
class ApiException extends \Exception
{
    private AppErrorCode $errorCode;
    private ?array $additionalData;
    
    public function __construct(
        AppErrorCode $errorCode,
        ?string $message = null,
        ?array $additionalData = null,
        int $code = 0,
        ?\Throwable $previous = null
    ) {
        $this->errorCode = $errorCode;
        $this->additionalData = $additionalData;
        
        parent::__construct(
            $message ?? $errorCode->getJapaneseMessage(),
            $code,
            $previous
        );
    }
    
    public function getErrorCode(): AppErrorCode
    {
        return $this->errorCode;
    }
    
    public function getAdditionalData(): ?array
    {
        return $this->additionalData;
    }
    
    // APIレスポンスに変換
    public function toApiResponse(string $locale = 'ja'): ApiResponse
    {
        $httpStatus = $this->errorCode->getHttpStatus();
        $message = $this->getMessage() ?: $this->errorCode->getLocalizedMessage($locale);
        
        $errors = [
            'code' => $this->errorCode->value,
            'message' => $message,
        ];
        
        if ($this->additionalData) {
            $errors['details'] = $this->additionalData;
        }
        
        return ApiResponse::error($httpStatus, [$errors], $httpStatus->getDescription());
    }
}

実際の使用例

// API コントローラーでの使用例
class UserController
{
    private UserRepository $userRepository;
    
    public function __construct(UserRepository $userRepository)
    {
        $this->userRepository = $userRepository;
    }
    
    // ユーザー登録API
    public function register(): void
    {
        try {
            // リクエストデータの取得
            $requestData = json_decode(file_get_contents('php://input'), true);
            
            // バリデーション
            if (empty($requestData['email'])) {
                throw new ApiException(
                    AppErrorCode::VALIDATION_REQUIRED_FIELD,
                    null,
                    ['field' => 'email']
                );
            }
            
            // メールアドレスの重複チェック
            if ($this->userRepository->emailExists($requestData['email'])) {
                throw new ApiException(AppErrorCode::USER_EMAIL_DUPLICATE);
            }
            
            // ユーザー作成
            $user = $this->userRepository->create($requestData);
            
            // 成功レスポンス
            ApiResponse::success(['user' => $user], 'ユーザーが正常に登録されました')->send();
        } catch (ApiException $e) {
            // APIエラーの処理
            $e->toApiResponse()->send();
        } catch (\Exception $e) {
            // 予期しないエラーの処理
            $systemError = new ApiException(
                AppErrorCode::SYSTEM_UNEXPECTED_ERROR,
                $e->getMessage()
            );
            $systemError->toApiResponse()->send();
        }
    }
    
    // ユーザー取得API
    public function getUser(int $id): void
    {
        try {
            // ユーザー検索
            $user = $this->userRepository->findById($id);
            
            if (!$user) {
                throw new ApiException(AppErrorCode::USER_NOT_FOUND);
            }
            
            // 成功レスポンス
            ApiResponse::success(['user' => $user])->send();
        } catch (ApiException $e) {
            $e->toApiResponse()->send();
        } catch (\Exception $e) {
            $systemError = new ApiException(AppErrorCode::SYSTEM_UNEXPECTED_ERROR);
            $systemError->toApiResponse()->send();
        }
    }
}

グローバルエラーハンドラーの実装

アプリケーション全体で一貫したエラーハンドリングを実現するために、グローバルエラーハンドラーを実装できます:

// グローバルエラーハンドラー
function globalErrorHandler($errno, $errstr, $errfile, $errline): bool
{
    // エラーを例外に変換
    throw new \ErrorException($errstr, 0, $errno, $errfile, $errline);
    return true;
}

// 例外ハンドラー
function globalExceptionHandler(\Throwable $exception): void
{
    // 開発環境での詳細情報
    $isDebug = ($_ENV['APP_ENV'] ?? '') === 'development';
    
    if ($exception instanceof ApiException) {
        $response = $exception->toApiResponse();
    } else {
        $errorData = null;
        
        if ($isDebug) {
            $errorData = [
                'message' => $exception->getMessage(),
                'file' => $exception->getFile(),
                'line' => $exception->getLine(),
                'trace' => $exception->getTraceAsString(),
            ];
        }
        
        $errorCode = AppErrorCode::SYSTEM_UNEXPECTED_ERROR;
        $response = ApiResponse::error(
            ApiResponseStatus::INTERNAL_ERROR,
            [
                [
                    'code' => $errorCode->value,
                    'message' => $errorCode->getJapaneseMessage(),
                    'details' => $errorData
                ]
            ]
        );
    }
    
    // エラーをログに記録
    error_log("Exception: {$exception->getMessage()} in {$exception->getFile()} on line {$exception->getLine()}");
    
    // レスポンスを送信
    $response->send();
}

// ハンドラーを登録
set_error_handler('globalErrorHandler');
set_exception_handler('globalExceptionHandler');

Enumを活用したAPIエラーハンドリングのメリット

  1. 一貫性 – 全てのエラーが同じ形式で返される
  2. タイプセーフ – コンパイル時のチェックでエラーコードのタイプミスを防止
  3. 集中管理 – エラーコードとメッセージが一箇所に集約
  4. 国際化対応 – 言語ごとのメッセージを管理しやすい
  5. クライアント親和性 – クライアント側で予測・解析しやすい
  6. ドキュメント化 – エラーコードが自己文書化される

これらのパターンを活用することで、より堅牢でメンテナンス性の高いAPIエラーハンドリングが実現でき、結果として開発者とエンドユーザー双方の体験が向上します。

一貫性のあるAPIレスポンスをEnumで構築する

APIを設計する際、一貫性のあるレスポンス構造はクライアント開発者の使い勝手を大きく左右します。PHPのEnumを活用することで、予測可能で型安全なAPIレスポンスを構築できます。

APIレスポンスの標準構造

まず、APIレスポンスの標準構造を定義し、それをEnumで管理する方法を見ていきましょう:

// APIレスポンスの状態をEnumで定義
enum ApiStatus: string
{
    case OK = 'ok';
    case ERROR = 'error';
    case FAIL = 'fail';
    
    // 説明を取得
    public function getDescription(): string
    {
        return match($this) {
            self::OK => '処理に成功しました',
            self::ERROR => 'サーバーエラーが発生しました',
            self::FAIL => 'リクエスト内容に問題があります',
        };
    }
    
    // HTTPステータスコードを取得
    public function getHttpStatusCode(): int
    {
        return match($this) {
            self::OK => 200,
            self::ERROR => 500,
            self::FAIL => 400,
        };
    }
}

// リソースタイプをEnumで定義
enum ResourceType: string
{
    case USER = 'user';
    case PRODUCT = 'product';
    case ORDER = 'order';
    case PAYMENT = 'payment';
    
    // リソース固有の情報を取得
    public function getInfo(): array
    {
        return match($this) {
            self::USER => [
                'collection' => 'users',
                'identifier' => 'id',
                'displayName' => 'ユーザー'
            ],
            self::PRODUCT => [
                'collection' => 'products',
                'identifier' => 'product_id',
                'displayName' => '商品'
            ],
            self::ORDER => [
                'collection' => 'orders',
                'identifier' => 'order_id',
                'displayName' => '注文'
            ],
            self::PAYMENT => [
                'collection' => 'payments',
                'identifier' => 'payment_id',
                'displayName' => '支払い'
            ],
        };
    }
    
    // 複数形コレクション名を取得
    public function getCollectionName(): string
    {
        return $this->getInfo()['collection'];
    }
    
    // 識別子フィールド名を取得
    public function getIdentifierField(): string
    {
        return $this->getInfo()['identifier'];
    }
    
    // 表示名を取得
    public function getDisplayName(): string
    {
        return $this->getInfo()['displayName'];
    }
}

// 標準APIレスポンスクラス
class ApiResponse
{
    private ApiStatus $status;
    private mixed $data;
    private ?array $meta;
    private ?array $error;
    
    public function __construct(
        ApiStatus $status,
        mixed $data = null,
        ?array $meta = null,
        ?array $error = null
    ) {
        $this->status = $status;
        $this->data = $data;
        $this->meta = $meta;
        $this->error = $error;
    }
    
    // 成功レスポンスを作成
    public static function success(mixed $data, ?array $meta = null): self
    {
        return new self(ApiStatus::OK, $data, $meta);
    }
    
    // サーバーエラーレスポンスを作成
    public static function error(string $message, string $code = 'server_error', ?array $details = null): self
    {
        return new self(
            ApiStatus::ERROR,
            null,
            null,
            [
                'message' => $message,
                'code' => $code,
                'details' => $details
            ]
        );
    }
    
    // バリデーションエラーレスポンスを作成
    public static function validationFail(array $errors, string $message = 'バリデーションエラーが発生しました'): self
    {
        return new self(
            ApiStatus::FAIL,
            null,
            null,
            [
                'message' => $message,
                'code' => 'validation_error',
                'errors' => $errors
            ]
        );
    }
    
    // レスポンスデータをJSONに変換
    public function toJson(): string
    {
        $response = [
            'status' => $this->status->value,
        ];
        
        if ($this->data !== null) {
            $response['data'] = $this->data;
        }
        
        if ($this->meta !== null) {
            $response['meta'] = $this->meta;
        }
        
        if ($this->error !== null) {
            $response['error'] = $this->error;
        }
        
        return json_encode($response, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
    }
    
    // レスポンスをクライアントに送信
    public function send(): void
    {
        http_response_code($this->status->getHttpStatusCode());
        header('Content-Type: application/json; charset=utf-8');
        echo $this->toJson();
        exit;
    }
}

リソースモデルの標準化

次に、APIで扱うリソースのレスポンス構造を標準化する例を示します:

// APIリソースの基底クラス
abstract class ApiResource
{
    protected mixed $model;
    
    public function __construct(mixed $model)
    {
        $this->model = $model;
    }
    
    // リソースタイプを取得(サブクラスで実装)
    abstract public function getResourceType(): ResourceType;
    
    // リソースを配列に変換(サブクラスで実装)
    abstract public function toArray(): array;
    
    // リソースコレクションを作成
    public static function collection(array $items): array
    {
        return array_map(fn($item) => (new static($item))->toArray(), $items);
    }
}

// ユーザーリソースの例
class UserResource extends ApiResource
{
    public function getResourceType(): ResourceType
    {
        return ResourceType::USER;
    }
    
    public function toArray(): array
    {
        // ユーザーモデルをAPIレスポンス用の配列に変換
        return [
            'id' => $this->model->id,
            'name' => $this->model->name,
            'email' => $this->model->email,
            'created_at' => $this->model->created_at->format('Y-m-d H:i:s'),
            'type' => $this->getResourceType()->value,
        ];
    }
}

// 商品リソースの例
class ProductResource extends ApiResource
{
    public function getResourceType(): ResourceType
    {
        return ResourceType::PRODUCT;
    }
    
    public function toArray(): array
    {
        return [
            'product_id' => $this->model->id,
            'name' => $this->model->name,
            'price' => $this->model->price,
            'description' => $this->model->description,
            'stock' => $this->model->stock,
            'type' => $this->getResourceType()->value,
        ];
    }
}

実際のAPIコントローラーでの使用例

これらのクラスを使用して、統一されたAPIレスポンスを生成するコントローラーの例を示します:

// ユーザーAPIコントローラー
class UserApiController
{
    private UserRepository $userRepository;
    
    public function __construct(UserRepository $userRepository)
    {
        $this->userRepository = $userRepository;
    }
    
    // ユーザー一覧を取得
    public function index(): void
    {
        try {
            $users = $this->userRepository->findAll();
            
            // コレクションメタデータ
            $meta = [
                'total' => count($users),
                'page' => 1,
                'per_page' => 20,
            ];
            
            // 成功レスポンスを送信
            ApiResponse::success(
                UserResource::collection($users),
                $meta
            )->send();
        } catch (\Exception $e) {
            // エラーレスポンスを送信
            ApiResponse::error($e->getMessage())->send();
        }
    }
    
    // 単一ユーザーを取得
    public function show(int $id): void
    {
        try {
            $user = $this->userRepository->findById($id);
            
            if (!$user) {
                ApiResponse::validationFail(
                    ['id' => 'ユーザーが見つかりません'],
                    'ユーザーが存在しません'
                )->send();
                return;
            }
            
            // 成功レスポンスを送信
            ApiResponse::success(
                (new UserResource($user))->toArray()
            )->send();
        } catch (\Exception $e) {
            ApiResponse::error($e->getMessage())->send();
        }
    }
    
    // ユーザーを作成
    public function store(): void
    {
        try {
            // リクエストデータを取得
            $requestData = json_decode(file_get_contents('php://input'), true);
            
            // バリデーション
            $errors = $this->validateUserData($requestData);
            if (!empty($errors)) {
                ApiResponse::validationFail($errors)->send();
                return;
            }
            
            // ユーザーを作成
            $user = $this->userRepository->create($requestData);
            
            // 成功レスポンスを送信
            ApiResponse::success(
                (new UserResource($user))->toArray()
            )->send();
        } catch (\Exception $e) {
            ApiResponse::error($e->getMessage())->send();
        }
    }
    
    // バリデーション処理
    private function validateUserData(array $data): array
    {
        $errors = [];
        
        if (empty($data['name'])) {
            $errors['name'] = '名前は必須です';
        }
        
        if (empty($data['email'])) {
            $errors['email'] = 'メールアドレスは必須です';
        } elseif (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
            $errors['email'] = '有効なメールアドレスを入力してください';
        } elseif ($this->userRepository->emailExists($data['email'])) {
            $errors['email'] = 'このメールアドレスは既に使用されています';
        }
        
        if (empty($data['password'])) {
            $errors['password'] = 'パスワードは必須です';
        } elseif (strlen($data['password']) < 8) {
            $errors['password'] = 'パスワードは8文字以上にしてください';
        }
        
        return $errors;
    }
}

APIアクションタイプのEnum

APIで実行できるアクションを表現するEnumも、一貫性のある設計に役立ちます:

// APIアクションタイプ
enum ApiActionType: string
{
    case LIST = 'list';       // リソース一覧取得
    case GET = 'get';         // 単一リソース取得
    case CREATE = 'create';   // リソース作成
    case UPDATE = 'update';   // リソース更新
    case DELETE = 'delete';   // リソース削除
    case BULK = 'bulk';       // 一括処理
    
    // アクションの説明を取得
    public function getDescription(): string
    {
        return match($this) {
            self::LIST => 'リソース一覧の取得',
            self::GET => '単一リソースの取得',
            self::CREATE => 'リソースの新規作成',
            self::UPDATE => 'リソースの更新',
            self::DELETE => 'リソースの削除',
            self::BULK => 'リソースの一括処理',
        };
    }
    
    // 対応するHTTPメソッドを取得
    public function getHttpMethod(): string
    {
        return match($this) {
            self::LIST, self::GET => 'GET',
            self::CREATE => 'POST',
            self::UPDATE => 'PUT',
            self::DELETE => 'DELETE',
            self::BULK => 'POST', // BULKはPOSTで処理
        };
    }
    
    // アクセス権限名を取得
    public function getPermissionName(ResourceType $resourceType): string
    {
        $action = strtolower($this->value);
        $resource = strtolower($resourceType->value);
        
        return "{$action}_{$resource}";
    }
}

APIレスポンスページネーション用のEnum

ページネーションのスタイルを管理するEnumも実装できます:

// ページネーションタイプ
enum PaginationType: string
{
    case OFFSET = 'offset';  // オフセットベース (page, per_page)
    case CURSOR = 'cursor';  // カーソルベース (after, before)
    case NONE = 'none';      // ページネーションなし
    
    // メタデータを生成
    public function generateMeta(array $params, int $totalItems): array
    {
        return match($this) {
            self::OFFSET => [
                'total' => $totalItems,
                'per_page' => $params['per_page'] ?? 20,
                'current_page' => $params['page'] ?? 1,
                'last_page' => ceil($totalItems / ($params['per_page'] ?? 20)),
                'from' => (($params['page'] ?? 1) - 1) * ($params['per_page'] ?? 20) + 1,
                'to' => min($totalItems, (($params['page'] ?? 1) * ($params['per_page'] ?? 20))),
            ],
            self::CURSOR => [
                'total' => $totalItems,
                'per_page' => $params['limit'] ?? 20,
                'has_more' => ($params['offset'] ?? 0) + ($params['limit'] ?? 20) < $totalItems,
                'next_cursor' => $params['has_more'] ? $params['next_cursor'] : null,
            ],
            self::NONE => [
                'total' => $totalItems,
                'filtered' => count($params['filters'] ?? []) > 0,
            ],
        };
    }
}

実際のAPIリクエスト・レスポンス例

これらの要素を組み合わせて、実際のAPIリクエストとレスポンスの例を示します:

リクエスト:

GET /api/users/123

レスポンス:

{
  "status": "ok",
  "data": {
    "id": 123,
    "name": "山田太郎",
    "email": "yamada@example.com",
    "created_at": "2023-01-15 08:30:45",
    "type": "user"
  }
}

エラーレスポンス例:

{
  "status": "fail",
  "error": {
    "message": "バリデーションエラーが発生しました",
    "code": "validation_error",
    "errors": {
      "email": "このメールアドレスは既に使用されています",
      "password": "パスワードは8文字以上にしてください"
    }
  }
}

一貫性のあるAPIレスポンス設計のメリット

  1. 予測可能性 – クライアント開発者が構造を予測しやすく、連携がスムーズ
  2. 効率的なエラーハンドリング – 統一されたエラー形式により、クライアント側での処理が簡素化
  3. コード再利用 – 共通のレスポンス構造により、コードの重複が減少
  4. 拡張性 – 新しいリソースやエラータイプの追加が容易
  5. ドキュメント生成 – Enumに基づいた自動ドキュメント生成が可能
  6. テスト容易性 – 構造が予測可能なため、テストの作成が容易

実装のベストプラクティス

  1. レスポンス構造をシンプルに保つ – 必要最小限の階層を維持
  2. 一貫したフィールド名 – 全てのレスポンスで同じフィールド名規則を使用
  3. HTTPステータスコードの適切な使用 – レスポンスのstatusフィールドとHTTPステータスコードを一致させる
  4. バージョニングの考慮 – APIの進化に合わせて拡張可能な設計にする
  5. 国際化対応 – エラーメッセージなどは多言語化を考慮する
  6. ドキュメントとの連携 – OpenAPIなどの仕様と整合させる

Enumを活用した一貫性のあるAPIレスポンス設計は、開発効率と利用者体験の両方を向上させる強力な手法です。型安全性と明確な構造により、メンテナンス性の高いAPIを構築することができます。

例外処理とエラーコードをEnumで管理する方法

効果的な例外処理とエラーコード管理は、堅牢なアプリケーション開発の基盤です。PHPのEnumを活用することで、型安全で一貫性のあるエラー管理システムを構築できます。

エラーコードのEnum管理

従来のエラーコード管理(クラス定数や配列)と比較して、Enumを使用する主なメリットは以下の通りです:

  1. 型安全性 – エラーコードが特定の型として扱われる
  2. IDE補完 – コード入力時にエラーコードの候補が表示される
  3. 集中管理 – エラーコードとその関連情報を一箇所に集約
  4. 自己文書化 – エラーコードの意味や用途がコードとして明示される

以下は、アプリケーション全体でのエラーコード管理の例です:

// アプリケーションのエラーコードをEnumで定義
enum AppErrorCode: string
{
    // 一般エラー (10xxx)
    case UNKNOWN_ERROR = 'E10001';
    case INVALID_CONFIGURATION = 'E10002';
    case RESOURCE_NOT_FOUND = 'E10003';
    
    // 認証・認可エラー (20xxx)
    case AUTHENTICATION_FAILED = 'E20001';
    case INVALID_CREDENTIALS = 'E20002';
    case SESSION_EXPIRED = 'E20003';
    case INSUFFICIENT_PERMISSIONS = 'E20004';
    
    // 入力バリデーションエラー (30xxx)
    case INVALID_INPUT = 'E30001';
    case REQUIRED_FIELD_MISSING = 'E30002';
    case INVALID_FORMAT = 'E30003';
    case INVALID_VALUE_RANGE = 'E30004';
    
    // データベースエラー (40xxx)
    case DATABASE_CONNECTION_ERROR = 'E40001';
    case QUERY_EXECUTION_ERROR = 'E40002';
    case DUPLICATE_ENTRY = 'E40003';
    case DATA_INTEGRITY_ERROR = 'E40004';
    
    // 外部サービス連携エラー (50xxx)
    case API_CONNECTION_ERROR = 'E50001';
    case API_RESPONSE_ERROR = 'E50002';
    case API_TIMEOUT = 'E50003';
    
    // ファイル操作エラー (60xxx)
    case FILE_NOT_FOUND = 'E60001';
    case FILE_PERMISSION_DENIED = 'E60002';
    case FILE_UPLOAD_ERROR = 'E60003';
    
    // エラーのカテゴリを取得
    public function getCategory(): string
    {
        $code = substr($this->value, 1, 2);
        return match($code) {
            '10' => 'General',
            '20' => 'Authentication',
            '30' => 'Validation',
            '40' => 'Database',
            '50' => 'ExternalAPI',
            '60' => 'FileSystem',
            default => 'Unknown',
        };
    }
    
    // HTTPステータスコードを取得
    public function getHttpStatus(): int
    {
        return match($this) {
            // 一般エラー
            self::UNKNOWN_ERROR => 500,
            self::INVALID_CONFIGURATION => 500,
            self::RESOURCE_NOT_FOUND => 404,
            
            // 認証・認可エラー
            self::AUTHENTICATION_FAILED, 
            self::INVALID_CREDENTIALS, 
            self::SESSION_EXPIRED => 401,
            self::INSUFFICIENT_PERMISSIONS => 403,
            
            // 入力バリデーションエラー
            self::INVALID_INPUT,
            self::REQUIRED_FIELD_MISSING,
            self::INVALID_FORMAT,
            self::INVALID_VALUE_RANGE => 422,
            
            // データベースエラー
            self::DATABASE_CONNECTION_ERROR,
            self::QUERY_EXECUTION_ERROR,
            self::DATA_INTEGRITY_ERROR => 500,
            self::DUPLICATE_ENTRY => 409,
            
            // 外部サービス連携エラー
            self::API_CONNECTION_ERROR,
            self::API_RESPONSE_ERROR,
            self::API_TIMEOUT => 502,
            
            // ファイル操作エラー
            self::FILE_NOT_FOUND => 404,
            self::FILE_PERMISSION_DENIED => 403,
            self::FILE_UPLOAD_ERROR => 422,
        };
    }
    
    // エラーメッセージの取得
    public function getMessage(): string
    {
        return match($this) {
            // 一般エラー
            self::UNKNOWN_ERROR => '予期しないエラーが発生しました',
            self::INVALID_CONFIGURATION => '設定エラーが発生しました',
            self::RESOURCE_NOT_FOUND => 'リソースが見つかりません',
            
            // 認証・認可エラー
            self::AUTHENTICATION_FAILED => '認証に失敗しました',
            self::INVALID_CREDENTIALS => 'ユーザー名またはパスワードが正しくありません',
            self::SESSION_EXPIRED => 'セッションの有効期限が切れました。再ログインしてください',
            self::INSUFFICIENT_PERMISSIONS => 'この操作を行う権限がありません',
            
            // 入力バリデーションエラー
            self::INVALID_INPUT => '入力データが無効です',
            self::REQUIRED_FIELD_MISSING => '必須項目が入力されていません',
            self::INVALID_FORMAT => '入力形式が正しくありません',
            self::INVALID_VALUE_RANGE => '入力値が許容範囲外です',
            
            // データベースエラー
            self::DATABASE_CONNECTION_ERROR => 'データベース接続エラーが発生しました',
            self::QUERY_EXECUTION_ERROR => 'クエリ実行中にエラーが発生しました',
            self::DUPLICATE_ENTRY => 'データが重複しています',
            self::DATA_INTEGRITY_ERROR => 'データ整合性エラーが発生しました',
            
            // 外部サービス連携エラー
            self::API_CONNECTION_ERROR => '外部サービスとの接続に失敗しました',
            self::API_RESPONSE_ERROR => '外部サービスからのレスポンスにエラーがあります',
            self::API_TIMEOUT => '外部サービスからの応答がタイムアウトしました',
            
            // ファイル操作エラー
            self::FILE_NOT_FOUND => 'ファイルが見つかりません',
            self::FILE_PERMISSION_DENIED => 'ファイルのアクセス権限がありません',
            self::FILE_UPLOAD_ERROR => 'ファイルのアップロードに失敗しました',
        };
    }
    
    // 開発者向けの詳細メッセージ(デバッグ用)
    public function getDeveloperMessage(): string
    {
        return match($this) {
            self::UNKNOWN_ERROR => 'An unhandled exception occurred in the application',
            self::INVALID_CONFIGURATION => 'Application configuration is invalid or missing required values',
            // 他のエラーコードも同様に定義
            default => 'See logs for detailed error information',
        };
    }
    
    // ログレベルを取得
    public function getLogLevel(): string
    {
        return match($this->getCategory()) {
            'General' => 'ERROR',
            'Authentication' => 'WARNING',
            'Validation' => 'NOTICE',
            'Database' => 'ERROR',
            'ExternalAPI' => 'ERROR',
            'FileSystem' => 'WARNING',
            default => 'ERROR',
        };
    }
    
    // ユーザーへの推奨アクションを取得
    public function getSuggestedAction(): ?string
    {
        return match($this) {
            self::SESSION_EXPIRED => '再度ログインしてください',
            self::INVALID_CREDENTIALS => 'ユーザー名とパスワードを確認してください',
            self::REQUIRED_FIELD_MISSING, 
            self::INVALID_FORMAT,
            self::INVALID_VALUE_RANGE => '入力内容を確認して再度お試しください',
            self::API_TIMEOUT => 'しばらく経ってから再度お試しください',
            default => null,
        };
    }
}

Enumを活用したカスタム例外

エラーコードEnumと連携するカスタム例外クラスを実装することで、型安全なエラー処理システムが構築できます:

// エラーコードを持つ基底例外クラス
abstract class AppException extends \Exception
{
    protected AppErrorCode $errorCode;
    protected ?array $context;
    
    public function __construct(
        AppErrorCode $errorCode,
        ?string $message = null,
        ?array $context = null,
        int $code = 0,
        ?\Throwable $previous = null
    ) {
        $this->errorCode = $errorCode;
        $this->context = $context;
        
        parent::__construct(
            $message ?? $errorCode->getMessage(),
            $code,
            $previous
        );
    }
    
    public function getErrorCode(): AppErrorCode
    {
        return $this->errorCode;
    }
    
    public function getContext(): ?array
    {
        return $this->context;
    }
    
    // APIレスポンス用のエラー情報を取得
    public function getApiErrorResponse(): array
    {
        $response = [
            'code' => $this->errorCode->value,
            'message' => $this->getMessage(),
        ];
        
        $suggestedAction = $this->errorCode->getSuggestedAction();
        if ($suggestedAction) {
            $response['suggested_action'] = $suggestedAction;
        }
        
        return $response;
    }
    
    // ログ記録用のエラー情報を取得
    public function getLogContext(): array
    {
        $logContext = [
            'error_code' => $this->errorCode->value,
            'error_category' => $this->errorCode->getCategory(),
        ];
        
        if ($this->context) {
            $logContext['context'] = $this->context;
        }
        
        return $logContext;
    }
}

// 具体的な例外クラス(認証エラー)
class AuthenticationException extends AppException
{
    public function __construct(
        AppErrorCode $errorCode = AppErrorCode::AUTHENTICATION_FAILED,
        ?string $message = null,
        ?array $context = null,
        int $code = 0,
        ?\Throwable $previous = null
    ) {
        parent::__construct($errorCode, $message, $context, $code, $previous);
    }
}

// 具体的な例外クラス(バリデーションエラー)
class ValidationException extends AppException
{
    private array $validationErrors;
    
    public function __construct(
        array $validationErrors,
        AppErrorCode $errorCode = AppErrorCode::INVALID_INPUT,
        ?string $message = null,
        ?array $context = null,
        int $code = 0,
        ?\Throwable $previous = null
    ) {
        $this->validationErrors = $validationErrors;
        parent::__construct($errorCode, $message, $context, $code, $previous);
    }
    
    public function getValidationErrors(): array
    {
        return $this->validationErrors;
    }
    
    // APIレスポンス用のエラー情報をオーバーライド
    public function getApiErrorResponse(): array
    {
        $response = parent::getApiErrorResponse();
        $response['errors'] = $this->validationErrors;
        return $response;
    }
}

例外ハンドラーでの使用例

Enumベースの例外を効果的に扱うグローバル例外ハンドラーの例:

// グローバル例外ハンドラー
function globalExceptionHandler(\Throwable $exception): void
{
    // 環境設定
    $isDebug = ($_ENV['APP_ENV'] ?? '') === 'development';
    $logger = new Logger(); // 実際のロガーインスタンス
    
    // APIレスポンス準備
    $httpStatus = 500;
    $responseData = [
        'status' => 'error',
        'error' => [
            'code' => 'E10001', // デフォルトは未知のエラー
            'message' => 'サーバーエラーが発生しました'
        ]
    ];
    
    // アプリケーション例外の場合
    if ($exception instanceof AppException) {
        $errorCode = $exception->getErrorCode();
        $httpStatus = $errorCode->getHttpStatus();
        $responseData['error'] = $exception->getApiErrorResponse();
        
        // ログ記録
        $logLevel = $errorCode->getLogLevel();
        $logger->log($logLevel, $exception->getMessage(), $exception->getLogContext());
    } else {
        // 未処理の例外の場合
        if ($isDebug) {
            $responseData['error']['details'] = [
                'message' => $exception->getMessage(),
                'file' => $exception->getFile(),
                'line' => $exception->getLine(),
                'trace' => $exception->getTraceAsString()
            ];
        }
        
        // ログ記録
        $logger->error($exception->getMessage(), [
            'file' => $exception->getFile(),
            'line' => $exception->getLine(),
            'exception' => get_class($exception)
        ]);
    }
    
    // レスポンス送信
    http_response_code($httpStatus);
    header('Content-Type: application/json');
    echo json_encode($responseData);
    exit;
}

// ハンドラー登録
set_exception_handler('globalExceptionHandler');

実際の使用例

これらのコンポーネントを使用したコード例:

// ユーザー認証ロジックの例
function authenticateUser(string $username, string $password): User
{
    $user = UserRepository::findByUsername($username);
    
    if (!$user) {
        throw new AuthenticationException(
            AppErrorCode::INVALID_CREDENTIALS,
            null,
            ['username' => $username]
        );
    }
    
    if (!password_verify($password, $user->getPasswordHash())) {
        // 失敗回数のカウント
        LoginAttemptTracker::recordFailedAttempt($username);
        
        throw new AuthenticationException(
            AppErrorCode::INVALID_CREDENTIALS,
            null,
            ['username' => $username]
        );
    }
    
    if ($user->isLocked()) {
        throw new AuthenticationException(
            AppErrorCode::AUTHENTICATION_FAILED,
            'アカウントがロックされています',
            ['username' => $username, 'locked_until' => $user->getLockedUntil()]
        );
    }
    
    return $user;
}

// ユーザー入力バリデーションの例
function validateUserInput(array $data): array
{
    $errors = [];
    
    if (empty($data['name'])) {
        $errors['name'] = '名前は必須です';
    }
    
    if (empty($data['email'])) {
        $errors['email'] = 'メールアドレスは必須です';
    } elseif (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
        $errors['email'] = '有効なメールアドレスを入力してください';
    }
    
    if (!empty($errors)) {
        throw new ValidationException(
            $errors,
            AppErrorCode::INVALID_INPUT,
            'フォームの入力内容に問題があります'
        );
    }
    
    return $data;
}

// コントローラーでの使用例
try {
    $input = json_decode(file_get_contents('php://input'), true);
    $validatedData = validateUserInput($input);
    $user = authenticateUser($validatedData['username'], $validatedData['password']);
    
    // 成功レスポンス
    echo json_encode(['status' => 'success', 'data' => $user->toArray()]);
} catch (AppException $e) {
    // 例外は globalExceptionHandler によって処理される
}

Enumによるエラー管理のメリット

  1. 型安全性 – エラーコードは列挙型として定義され、タイプミスを防止
  2. 一貫性 – エラー処理とレポートが統一された形式で行われる
  3. 自己文書化 – コードベースにエラー情報と処理方法が明示されている
  4. 保守性 – 新しいエラーコードの追加が容易
  5. 拡張性 – 新しいカテゴリや処理方法を追加しやすい
  6. IDE補完 – 開発時にエラーコードの補完と検証が可能

国際化(多言語対応)

エラーメッセージの国際化も、Enumを使用して効率的に管理できます:

// 多言語対応のエラーメッセージ
enum ErrorMessageLanguage
{
    case ENGLISH;
    case JAPANESE;
    case SPANISH;
    case FRENCH;
    
    // 言語コードを取得
    public function getCode(): string
    {
        return match($this) {
            self::ENGLISH => 'en',
            self::JAPANESE => 'ja',
            self::SPANISH => 'es',
            self::FRENCH => 'fr',
        };
    }
}

// AppErrorCodeクラスに多言語メソッドを追加
enum AppErrorCode: string
{
    // 既存のケース定義...
    
    // 指定された言語でエラーメッセージを取得
    public function getLocalizedMessage(ErrorMessageLanguage $language): string
    {
        return match($language) {
            ErrorMessageLanguage::ENGLISH => $this->getEnglishMessage(),
            ErrorMessageLanguage::JAPANESE => $this->getJapaneseMessage(),
            ErrorMessageLanguage::SPANISH => $this->getSpanishMessage(),
            ErrorMessageLanguage::FRENCH => $this->getFrenchMessage(),
        };
    }
    
    // 英語メッセージ
    private function getEnglishMessage(): string
    {
        return match($this) {
            self::UNKNOWN_ERROR => 'An unexpected error occurred',
            self::INVALID_CREDENTIALS => 'Invalid username or password',
            // 他のエラーコードも同様に定義...
            default => 'An error occurred',
        };
    }
    
    // 日本語メッセージ
    private function getJapaneseMessage(): string
    {
        return match($this) {
            self::UNKNOWN_ERROR => '予期しないエラーが発生しました',
            self::INVALID_CREDENTIALS => 'ユーザー名またはパスワードが正しくありません',
            // 他のエラーコードも同様に定義...
            default => 'エラーが発生しました',
        };
    }
    
    // スペイン語メッセージ
    private function getSpanishMessage(): string
    {
        return match($this) {
            self::UNKNOWN_ERROR => 'Se produjo un error inesperado',
            self::INVALID_CREDENTIALS => 'Nombre de usuario o contraseña inválidos',
            // 他のエラーコードも同様に定義...
            default => 'Se produjo un error',
        };
    }
    
    // フランス語メッセージ
    private function getFrenchMessage(): string
    {
        return match($this) {
            self::UNKNOWN_ERROR => 'Une erreur inattendue s\'est produite',
            self::INVALID_CREDENTIALS => 'Nom d\'utilisateur ou mot de passe invalide',
            // 他のエラーコードも同様に定義...
            default => 'Une erreur s\'est produite',
        };
    }
}

実装のベストプラクティス

  1. エラーコードの命名規則を確立する
    • 一貫性のある形式(例:E10001
    • カテゴリごとにプレフィックスを決定
    • 数字やセマンティックな命名規則を使用
  2. エラーの詳細レベルを適切に設定する
    • ユーザー向けメッセージ(一般的で理解しやすい)
    • 開発者向けメッセージ(技術的で詳細)
    • ログ向けコンテキスト(デバッグに必要な情報)
  3. 重大度と責任の明確化
    • クライアントエラーとサーバーエラーを区別
    • エラーカテゴリの明確な定義
    • 適切なHTTPステータスコードとの対応
  4. エラーコードのドキュメント化
    • API仕様書への反映
    • Enumからドキュメントを自動生成
    • エラー対応ガイドの作成
  5. セキュリティへの配慮
    • 環境に応じたエラー詳細の制御
    • センシティブ情報の漏洩防止
    • 攻撃者に有用な情報の制限

Enumによる例外処理とエラーコード管理は、アプリケーションの品質と保守性を大きく向上させる強力なアプローチです。型安全性、一貫性、自己文書化といった特性により、エラー処理のミスを減らし、開発者体験とエンドユーザー体験の両方を向上させることができます。

LaravelでのEnum対応と活用テクニック

Laravel 9.0以降では、PHP 8.1のEnum型に対する包括的なサポートが導入されました。これにより、Eloquentモデル、バリデーション、マイグレーションなど様々な場面でEnumを活用できるようになりました。

Eloquentモデルでのキャスト

Laravel Eloquentの強力な機能の一つが属性キャストであり、これがEnumとシームレスに連携します:

// app/Enums/SubscriptionStatus.php
enum SubscriptionStatus: string
{
    case ACTIVE = 'active';
    case TRIAL = 'trial';
    case CANCELLED = 'cancelled';
    case PENDING = 'pending';
    case EXPIRED = 'expired';
    
    // 追加機能: アクティブ状態かどうかを判定
    public function isActive(): bool
    {
        return in_array($this, [self::ACTIVE, self::TRIAL]);
    }
    
    // 表示用ラベルを取得
    public function label(): string
    {
        return match($this) {
            self::ACTIVE => 'アクティブ',
            self::TRIAL => 'トライアル中',
            self::CANCELLED => 'キャンセル済み',
            self::PENDING => '保留中',
            self::EXPIRED => '期限切れ',
        };
    }
    
    // 色表示用クラスを取得
    public function colorClass(): string
    {
        return match($this) {
            self::ACTIVE => 'text-green-500',
            self::TRIAL => 'text-blue-500',
            self::CANCELLED => 'text-red-500',
            self::PENDING => 'text-yellow-500',
            self::EXPIRED => 'text-gray-500',
        };
    }
}

// app/Models/Subscription.php
class Subscription extends Model
{
    protected $fillable = [
        'user_id',
        'plan_id',
        'status',
        'start_date',
        'end_date',
    ];
    
    // 文字列からSubscriptionStatus Enumへの自動キャスト
    protected $casts = [
        'status' => SubscriptionStatus::class,
        'start_date' => 'datetime',
        'end_date' => 'datetime',
    ];
    
    // リレーションシップ
    public function user()
    {
        return $this->belongsTo(User::class);
    }
    
    public function plan()
    {
        return $this->belongsTo(Plan::class);
    }
    
    // Enumの機能を活用したスコープ
    public function scopeActive($query)
    {
        return $query->whereIn('status', [
            SubscriptionStatus::ACTIVE->value,
            SubscriptionStatus::TRIAL->value
        ]);
    }
    
    // ヘルパーメソッド
    public function cancel(): void
    {
        $this->status = SubscriptionStatus::CANCELLED;
        $this->save();
    }
    
    // Enumを利用した条件判定
    public function canReactivate(): bool
    {
        return $this->status === SubscriptionStatus::CANCELLED && 
               $this->end_date->isFuture();
    }
}

データベースから取得する際、statusカラムの値は自動的にSubscriptionStatusEnumに変換されます。これにより、型安全性が確保され、Enumのメソッドやプロパティを直接利用できます:

$subscription = Subscription::find(1);

// Enumの比較
if ($subscription->status === SubscriptionStatus::ACTIVE) {
    // アクティブなサブスクリプションの処理
}

// Enumのメソッド呼び出し
if ($subscription->status->isActive()) {
    // アクティブな処理
}

// ラベル表示
echo $subscription->status->label(); // 「アクティブ」など

// 条件に基づくフィルタリング
$activeSubscriptions = Subscription::active()->get();

フォームリクエストでのバリデーション

Laravel 9以降では、FormRequestでEnumを使用したバリデーションが簡単に実装できます:

// app/Http/Requests/SubscriptionRequest.php
class SubscriptionRequest extends FormRequest
{
    public function rules()
    {
        return [
            'user_id' => 'required|exists:users,id',
            'plan_id' => 'required|exists:plans,id',
            // Enumを使用したバリデーション
            'status' => ['required', Rule::enum(SubscriptionStatus::class)],
            'start_date' => 'required|date',
            'end_date' => 'required|date|after:start_date',
        ];
    }
}

Rule::enumメソッドは、入力値が指定されたEnumの有効な値であることを確認します。これにより、無効な値の受け入れを防止できます。

カスタムバリデーションルール

より細かなバリデーション要件がある場合は、カスタムルールを作成することもできます:

// app/Rules/ActiveSubscriptionStatus.php
class ActiveSubscriptionStatus implements Rule
{
    public function passes($attribute, $value)
    {
        try {
            $status = SubscriptionStatus::from($value);
            return $status->isActive();
        } catch (\ValueError $e) {
            return false;
        }
    }
    
    public function message()
    {
        return 'ステータスはアクティブな状態である必要があります。';
    }
}

// 使用例
'status' => ['required', new ActiveSubscriptionStatus],

マイグレーションでのEnum

LaravelのマイグレーションでもEnum値を使用できます:

// database/migrations/create_subscriptions_table.php
public function up()
{
    Schema::create('subscriptions', function (Blueprint $table) {
        $table->id();
        $table->foreignId('user_id')->constrained();
        $table->foreignId('plan_id')->constrained();
        // Enumのデフォルト値を指定
        $table->string('status')->default(SubscriptionStatus::PENDING->value);
        $table->timestamp('start_date');
        $table->timestamp('end_date');
        $table->timestamps();
        
        // MySQL固有のENUM型を使用する場合(オプション)
        // $table->enum('status', array_column(SubscriptionStatus::cases(), 'value'))
        //     ->default(SubscriptionStatus::PENDING->value);
    });
}

Bladeビューでの活用

Bladeテンプレート内でEnumを効果的に使用する例:

{{-- resources/views/subscriptions/show.blade.php --}}
<div class="subscription-card">
    <h2>サブスクリプション詳細</h2>
    
    <div class="status-badge {{ $subscription->status->colorClass() }}">
        {{ $subscription->status->label() }}
    </div>
    
    <div class="details">
        <p>プラン: {{ $subscription->plan->name }}</p>
        <p>開始日: {{ $subscription->start_date->format('Y年m月d日') }}</p>
        <p>終了日: {{ $subscription->end_date->format('Y年m月d日') }}</p>
    </div>
    
    <div class="actions">
        @if($subscription->status === \App\Enums\SubscriptionStatus::ACTIVE)
            <form action="{{ route('subscriptions.cancel', $subscription) }}" method="POST">
                @csrf
                @method('PATCH')
                <button type="submit" class="btn btn-danger">キャンセル</button>
            </form>
        @elseif($subscription->canReactivate())
            <form action="{{ route('subscriptions.reactivate', $subscription) }}" method="POST">
                @csrf
                @method('PATCH')
                <button type="submit" class="btn btn-success">再アクティブ化</button>
            </form>
        @endif
    </div>
</div>

セレクトボックスの作成

フォームでEnumを使用したセレクトボックスを生成する例:

{{-- resources/views/subscriptions/edit.blade.php --}}
<div class="form-group">
    <label for="status">ステータス</label>
    <select name="status" id="status" class="form-control">
        @foreach(\App\Enums\SubscriptionStatus::cases() as $status)
            <option 
                value="{{ $status->value }}" 
                {{ $subscription->status === $status ? 'selected' : '' }}
            >
                {{ $status->label() }}
            </option>
        @endforeach
    </select>
</div>

コントローラーでの活用

コントローラーでのEnum活用例:

// app/Http/Controllers/SubscriptionController.php
public function update(SubscriptionRequest $request, Subscription $subscription)
{
    $validated = $request->validated();
    
    // バリデーション済みデータから直接Enumを取得
    $status = SubscriptionStatus::from($validated['status']);
    
    // ステータス変更の履歴を記録
    if ($subscription->status !== $status) {
        SubscriptionStatusHistory::create([
            'subscription_id' => $subscription->id,
            'from_status' => $subscription->status->value,
            'to_status' => $status->value,
            'changed_by' => auth()->id(),
        ]);
    }
    
    $subscription->update($validated);
    
    return redirect()->route('subscriptions.show', $subscription)
        ->with('success', 'サブスクリプションが更新されました');
}

Enumヘルパートレイト

Laravelで再利用可能なEnumヘルパートレイトを作成できます:

// app/Traits/EnumHelpers.php
trait EnumHelpers
{
    // 全ケースの値を取得
    public static function values(): array
    {
        return array_column(self::cases(), 'value');
    }
    
    // selectボックス用のオプションを生成
    public static function options(): array
    {
        $options = [];
        foreach (self::cases() as $case) {
            $options[$case->value] = method_exists($case, 'label') 
                ? $case->label() 
                : $case->name;
        }
        return $options;
    }
    
    // ランダムなケースを取得(テスト用)
    public static function random(): static
    {
        $cases = self::cases();
        return $cases[array_rand($cases)];
    }
    
    // 特定のケースが含まれているか確認
    public static function contains(self $needle): bool
    {
        foreach (self::cases() as $case) {
            if ($case === $needle) {
                return true;
            }
        }
        return false;
    }
}

// 使用例
enum SubscriptionStatus: string
{
    use EnumHelpers;
    
    // ケースの定義...
}

// セレクトボックス用のオプション生成
$statusOptions = SubscriptionStatus::options();

Enum用マクロの定義

Laravelのサービスプロバイダで、Enum用のマクロを定義することもできます:

// app/Providers/AppServiceProvider.php
public function boot()
{
    // Blueprint拡張: Enum用カラム追加メソッド
    Blueprint::macro('enumString', function (string $column, string $enumClass) {
        /** @var Blueprint $this */
        return $this->string($column)->default($enumClass::cases()[0]->value);
    });
    
    // コレクション拡張: Enum値でフィルタリング
    Collection::macro('whereEnum', function (string $key, $enumValue) {
        /** @var Collection $this */
        if ($enumValue instanceof \UnitEnum) {
            $value = $enumValue instanceof \BackedEnum ? $enumValue->value : $enumValue->name;
        } else {
            $value = $enumValue;
        }
        
        return $this->where($key, $value);
    });
}

// 使用例
Schema::create('subscriptions', function (Blueprint $table) {
    $table->id();
    $table->enumString('status', SubscriptionStatus::class);
    // ...
});

$activeSubscriptions = $subscriptions->whereEnum('status', SubscriptionStatus::ACTIVE);

Livewire/Alpine.jsとの連携

LivewireコンポーネントでのEnum活用例:

// app/Http/Livewire/SubscriptionManager.php
class SubscriptionManager extends Component
{
    public Subscription $subscription;
    public string $status;
    
    public function mount(Subscription $subscription)
    {
        $this->subscription = $subscription;
        $this->status = $subscription->status->value;
    }
    
    public function updateStatus()
    {
        $this->validate([
            'status' => ['required', Rule::enum(SubscriptionStatus::class)],
        ]);
        
        $this->subscription->status = SubscriptionStatus::from($this->status);
        $this->subscription->save();
        
        $this->emit('statusUpdated');
    }
    
    public function render()
    {
        return view('livewire.subscription-manager', [
            'statusOptions' => SubscriptionStatus::options(),
        ]);
    }
}

LaravelでのEnum活用は、コードの型安全性、保守性、可読性を大きく向上させます。特にドメインロジックが複雑なアプリケーションでは、Eloquentモデルとの緊密な統合によって、より堅牢で表現力豊かなコードが書けるようになります。

SymfonyでのEnum活用とFormType連携

Symfony 6.0以降では、PHP 8.1のEnum型に対する包括的なサポートが導入されました。特にフォーム、バリデーション、Doctrine ORMとの連携において、Enumをシームレスに統合できるようになりました。

SymfonyのFormTypeでのEnum活用

Symfony 6.0で導入されたEnumTypeを使用すると、Enumベースのフォームフィールドを簡単に作成できます:

// src/Form/UserType.php
use App\Enum\UserRole;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\FormBuilderInterface;

class UserType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('name', TextType::class, [
                'label' => '名前',
            ])
            ->add('email', EmailType::class, [
                'label' => 'メールアドレス',
            ])
            // EnumTypeを使用
            ->add('role', EnumType::class, [
                'class' => UserRole::class,
                'label' => '権限',
                'choice_label' => function (UserRole $choice) {
                    return $choice->getLabel();
                },
                'placeholder' => '権限を選択してください',
                'required' => true,
            ]);
    }
}

EnumTypeは内部的にChoiceTypeを拡張しているため、ChoiceTypeのオプションをすべて使用できます。これにより、グループ化、属性のカスタマイズ、拡張選択などの高度な機能も利用可能です。

// Enumに表示ラベルを追加
enum UserRole: string
{
    case ADMIN = 'admin';
    case MANAGER = 'manager';
    case EDITOR = 'editor';
    case USER = 'user';
    
    // 表示ラベルの取得
    public function getLabel(): string
    {
        return match($this) {
            self::ADMIN => '管理者',
            self::MANAGER => 'マネージャー',
            self::EDITOR => '編集者',
            self::USER => '一般ユーザー',
        };
    }
    
    // グループの取得(フォームでのグループ化に使用)
    public function getGroup(): string
    {
        return match($this) {
            self::ADMIN, self::MANAGER => '管理職',
            self::EDITOR, self::USER => '一般職',
        };
    }
}

// グループ化オプションを使用
->add('role', EnumType::class, [
    'class' => UserRole::class,
    'choice_label' => fn(UserRole $choice) => $choice->getLabel(),
    'group_by' => fn(UserRole $choice) => $choice->getGroup(),
])

EnumTypeのカスタマイズ

特定のEnum用に専用のFormTypeを作成する例:

// src/Form/Type/UserRoleType.php
use App\Enum\UserRole;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\OptionsResolver\OptionsResolver;

class UserRoleType extends AbstractType
{
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'class' => UserRole::class,
            'choice_label' => function (UserRole $choice) {
                return $choice->getLabel();
            },
            'placeholder' => '権限を選択',
            'invalid_message' => '無効な権限が選択されました',
        ]);
    }
    
    public function getParent()
    {
        return EnumType::class;
    }
}

// 使用例
$builder->add('role', UserRoleType::class);

DoctrineとEnumの連携

Doctrine ORMとEnumを連携させるには、カスタムタイプの定義が必要です:

// src/Doctrine/Type/UserRoleType.php
use App\Enum\UserRole;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\StringType;

class UserRoleType extends StringType
{
    public const NAME = 'user_role';
    
    public function convertToDatabaseValue($value, AbstractPlatform $platform)
    {
        return $value instanceof UserRole ? $value->value : $value;
    }
    
    public function convertToPHPValue($value, AbstractPlatform $platform)
    {
        return $value !== null ? UserRole::tryFrom($value) : null;
    }
    
    public function getName(): string
    {
        return self::NAME;
    }
    
    public function requiresSQLCommentHint(AbstractPlatform $platform): bool
    {
        return true;
    }
}

タイプの登録はconfig/packages/doctrine.yamlで行います:

# config/packages/doctrine.yaml
doctrine:
    dbal:
        types:
            user_role: App\Doctrine\Type\UserRoleType

エンティティでの使用例:

// src/Entity/User.php
use App\Doctrine\Type\UserRoleType;
use App\Enum\UserRole;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class User
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;
    
    #[ORM\Column(length: 255)]
    private ?string $name = null;
    
    #[ORM\Column(length: 255)]
    private ?string $email = null;
    
    #[ORM\Column(type: UserRoleType::NAME)]
    private ?UserRole $role = null;
    
    // ゲッターとセッター
    public function getId(): ?int
    {
        return $this->id;
    }
    
    public function getName(): ?string
    {
        return $this->name;
    }
    
    public function setName(string $name): self
    {
        $this->name = $name;
        return $this;
    }
    
    public function getEmail(): ?string
    {
        return $this->email;
    }
    
    public function setEmail(string $email): self
    {
        $this->email = $email;
        return $this;
    }
    
    public function getRole(): ?UserRole
    {
        return $this->role;
    }
    
    public function setRole(UserRole $role): self
    {
        $this->role = $role;
        return $this;
    }
    
    // ロールに基づく判定メソッド
    public function isAdmin(): bool
    {
        return $this->role === UserRole::ADMIN;
    }
}

Symfony 6.2以降でのシンプルな連携

Symfony 6.2以降では、Doctrine EnumTypeに対するより簡潔なサポートが追加されました。doctrine.yamlに以下のように設定するだけで、Enumの自動マッピングが可能になります:

# config/packages/doctrine.yaml
doctrine:
    dbal:
        types:
            # 自動EnumマッピングをSymfonyに任せる
            enum: Doctrine\DBAL\Types\StringType
    orm:
        mappings:
            App:
                is_bundle: false
                type: attribute
                dir: '%kernel.project_dir%/src/Entity'
                prefix: 'App\Entity'
                alias: App
                # 自動Enum型マッピングを有効化
                enum_mappings:
                    App\Enum: string

この設定により、App\Enum名前空間のEnum型がstring型のカラムに自動的にマッピングされます。エンティティは以下のようにシンプルに定義できます:

// src/Entity/User.php (Symfony 6.2+)
use App\Enum\UserRole;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class User
{
    // ...
    
    #[ORM\Column]
    private ?UserRole $role = null;
    
    // ...
}

バリデーション制約

Symofny 6.0以降のバリデーションでは、Choice制約でEnum型を直接使用できます:

// src/Entity/User.php
use App\Enum\UserRole;
use Symfony\Component\Validator\Constraints as Assert;

class User
{
    // ...
    
    #[Assert\NotNull]
    #[Assert\Choice(callback: [UserRole::class, 'cases'])]
    private ?UserRole $role = null;
    
    // ...
}

または専用のEnum制約を使うこともできます:

// src/Validator/Constraints/ValidUserRole.php
use App\Enum\UserRole;
use Symfony\Component\Validator\Constraint;

#[\Attribute]
class ValidUserRole extends Constraint
{
    public $message = '選択された権限"{{ value }}"は無効です。';
}

// src/Validator/Constraints/ValidUserRoleValidator.php
use App\Enum\UserRole;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;

class ValidUserRoleValidator extends ConstraintValidator
{
    public function validate($value, Constraint $constraint)
    {
        if (null === $value) {
            return;
        }
        
        if (!$constraint instanceof ValidUserRole) {
            throw new UnexpectedTypeException($constraint, ValidUserRole::class);
        }
        
        if (!$value instanceof UserRole) {
            $this->context->buildViolation($constraint->message)
                ->setParameter('{{ value }}', $value)
                ->addViolation();
            return;
        }
        
        // 追加のバリデーションロジック(必要に応じて)
        // 例: 管理者権限が許可されているかなど
    }
}

// 使用例
#[Assert\NotNull]
#[ValidUserRole]
private ?UserRole $role = null;

Twigテンプレートでの使用

Twigテンプレート内でEnumを扱う例:

{# templates/user/show.html.twig #}
{% extends 'base.html.twig' %}

{% block body %}
    <h1>ユーザー詳細</h1>
    
    <div class="user-info">
        <p>名前: {{ user.name }}</p>
        <p>メールアドレス: {{ user.email }}</p>
        
        {% set roleClass = {
            'admin': 'badge-danger',
            'manager': 'badge-warning',
            'editor': 'badge-info',
            'user': 'badge-primary'
        } %}
        
        <p>
            権限: 
            <span class="badge {{ roleClass[user.role.value] }}">
                {{ user.role.label }}
            </span>
        </p>
        
        {% if user.role == constant('App\\Enum\\UserRole::ADMIN') %}
            <div class="admin-actions">
                <h3>管理者メニュー</h3>
                {# 管理者専用のアクション #}
            </div>
        {% endif %}
    </div>
{% endblock %}

サービスコンテナでのEnum活用

サービスコンテナの設定でEnumを活用する例:

# config/services.yaml
services:
    # RoleVoterにUserRoleを注入
    App\Security\Voter\RoleVoter:
        arguments:
            $adminRole: !php/const App\Enum\UserRole::ADMIN
// src/Security/Voter/RoleVoter.php
use App\Enum\UserRole;

class RoleVoter extends Voter
{
    private UserRole $adminRole;
    
    public function __construct(UserRole $adminRole)
    {
        $this->adminRole = $adminRole;
    }
    
    // ...
}

イベントとメッセージングでの活用

SymfonyのイベントシステムやMessengerコンポーネントでEnumを使用すると、より型安全なコードが書けます:

// src/Event/UserEvent.php
use App\Enum\UserEventType;

class UserEvent
{
    private UserEventType $type;
    
    public function __construct(
        UserEventType $type,
        private User $user
    ) {
        $this->type = $type;
    }
    
    public function getType(): UserEventType
    {
        return $this->type;
    }
    
    public function getUser(): User
    {
        return $this->user;
    }
}

// src/EventSubscriber/UserEventSubscriber.php
class UserEventSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents()
    {
        return [
            UserEvent::class => 'onUserEvent',
        ];
    }
    
    public function onUserEvent(UserEvent $event)
    {
        match($event->getType()) {
            UserEventType::REGISTERED => $this->handleRegistration($event->getUser()),
            UserEventType::ACTIVATED => $this->handleActivation($event->getUser()),
            UserEventType::PASSWORD_CHANGED => $this->handlePasswordChange($event->getUser()),
        };
    }
    
    // ...
}

Symfony 6.0以降でのEnum対応は非常に包括的で、フレームワークのほぼすべての部分でEnumを活用できます。特にフォーム処理とバリデーションにおいて、コードの可読性と型安全性が大幅に向上します。サービスコンテナとの連携も自然で、DDD(ドメイン駆動設計)アプローチを採用するプロジェクトに特に適しています。

PHP Enumを使いこなすための次のステップ

ここまで、PHP Enumの基本的な使い方から実践的な応用例まで見てきました。次に、Enumを本格的に活用するための発展的なトピックや、既存コードのリファクタリング手法について解説します。

設計の見直しとEnumリファクタリングのポイント

PHP Enumを導入することは、単なる構文変更以上の意味を持ちます。それはコードの設計と表現力を見直す良い機会です。以下のポイントに注目してEnumの導入とリファクタリングを進めましょう。

段階的なリファクタリング戦略

既存のシステムにEnumを導入する際は、段階的なアプローチが効果的です:

  1. 候補の特定
    • 文字列/整数定数を使用しているコード部分を特定
    • マジックナンバーや文字列リテラルを探す
    • ドメインの重要な区分や種別を表す部分を優先
  2. 優先順位の決定
    • ドメインの中核概念から着手
    • テストが充実している部分を優先
    • 変更頻度の高い部分から改善
  3. コンバーターの作成
    • 既存の値とEnum間の変換レイヤーを用意
    • データベースとの連携部分に注意
    • 一時的な互換性関数の導入
  4. テストの充実
    • リファクタリング前にテストカバレッジを向上
    • 境界値テストを追加
    • コンバーターの動作を確認するテスト
  5. 段階的置き換え
    • インターフェース部分から内部実装へ
    • 関連するクラスをグループ化して移行
    • コード全体の一貫性を保つ

リファクタリング例:定数クラスからEnumへ

// リファクタリング前: 定数クラス
class PaymentMethod
{
    public const CREDIT_CARD = 'credit_card';
    public const BANK_TRANSFER = 'bank_transfer';
    public const PAYPAL = 'paypal';
    public const CRYPTOCURRENCY = 'crypto';
    
    public static function getLabel($method)
    {
        return [
            self::CREDIT_CARD => 'クレジットカード',
            self::BANK_TRANSFER => '銀行振込',
            self::PAYPAL => 'PayPal',
            self::CRYPTOCURRENCY => '暗号通貨',
        ][$method] ?? '不明';
    }
    
    public static function getOptions()
    {
        return [
            self::CREDIT_CARD => self::getLabel(self::CREDIT_CARD),
            self::BANK_TRANSFER => self::getLabel(self::BANK_TRANSFER),
            self::PAYPAL => self::getLabel(self::PAYPAL),
            self::CRYPTOCURRENCY => self::getLabel(self::CRYPTOCURRENCY),
        ];
    }
}

// リファクタリング後: Enum
enum PaymentMethod: string
{
    case CREDIT_CARD = 'credit_card';
    case BANK_TRANSFER = 'bank_transfer';
    case PAYPAL = 'paypal';
    case CRYPTOCURRENCY = 'crypto';
    
    public function getLabel(): string
    {
        return match($this) {
            self::CREDIT_CARD => 'クレジットカード',
            self::BANK_TRANSFER => '銀行振込',
            self::PAYPAL => 'PayPal',
            self::CRYPTOCURRENCY => '暗号通貨',
        };
    }
    
    public static function getOptions(): array
    {
        return array_reduce(
            self::cases(),
            fn($options, $case) => $options + [$case->value => $case->getLabel()],
            []
        );
    }
    
    // Enumならではの新機能: オンライン決済かどうかを判定
    public function isOnlinePayment(): bool
    {
        return match($this) {
            self::CREDIT_CARD, self::PAYPAL, self::CRYPTOCURRENCY => true,
            self::BANK_TRANSFER => false,
        };
    }
}

移行期の互換性レイヤー

大規模なリファクタリングでは、一時的な互換性レイヤーが有用です:

// 移行期の互換性レイヤー
class LegacyPaymentMethod
{
    // 従来のコードと互換性のある静的メソッド
    public static function getLabel($method)
    {
        return PaymentMethod::tryFrom($method)?->getLabel() ?? '不明';
    }
    
    public static function getOptions()
    {
        return PaymentMethod::getOptions();
    }
    
    // 定数アクセスの互換性(PHP 8.1の__callStatic利用)
    public static function __callStatic($name, $arguments)
    {
        foreach (PaymentMethod::cases() as $case) {
            if ($case->name === $name) {
                return $case->value;
            }
        }
        
        throw new \Error("Undefined constant PaymentMethod::$name");
    }
}

発展的なEnum活用パターン

基本的な使い方を超えて、より発展的なパターンを取り入れることで、Enumの価値を最大化できます:

  1. ファクトリーメソッドパターン Enumをファクトリーとして使用し、適切なオブジェクトを生成します: enum ReportType: string { case DAILY = 'daily'; case WEEKLY = 'weekly'; case MONTHLY = 'monthly'; case QUARTERLY = 'quarterly'; case YEARLY = 'yearly'; // ファクトリーメソッド public function createReport(array $data): Report { return match($this) { self::DAILY => new DailyReport($data), self::WEEKLY => new WeeklyReport($data), self::MONTHLY => new MonthlyReport($data), self::QUARTERLY => new QuarterlyReport($data), self::YEARLY => new YearlyReport($data), }; } // 期間計算メソッド public function calculateDateRange(\DateTimeInterface $referenceDate): array { return match($this) { self::DAILY => [ 'start' => (clone $referenceDate)->setTime(0, 0), 'end' => (clone $referenceDate)->setTime(23, 59, 59), ], self::WEEKLY => [ 'start' => (clone $referenceDate)->modify('monday this week')->setTime(0, 0), 'end' => (clone $referenceDate)->modify('sunday this week')->setTime(23, 59, 59), ], // 他の期間も同様に... }; } }
  2. ビジターパターン Enumとビジターパターンを組み合わせて、拡張性を高めます: // ビジターインターフェース interface ShippingMethodVisitor { public function visitStandard(ShippingMethod $method): mixed; public function visitExpress(ShippingMethod $method): mixed; public function visitInternational(ShippingMethod $method): mixed; } // Enum側の実装 enum ShippingMethod: string { case STANDARD = 'standard'; case EXPRESS = 'express'; case INTERNATIONAL = 'international'; // ビジターパターンの実装 public function accept(ShippingMethodVisitor $visitor): mixed { return match($this) { self::STANDARD => $visitor->visitStandard($this), self::EXPRESS => $visitor->visitExpress($this), self::INTERNATIONAL => $visitor->visitInternational($this), }; } } // 料金計算ビジター class ShippingCostCalculator implements ShippingMethodVisitor { private float $weight; private string $destination; public function __construct(float $weight, string $destination) { $this->weight = $weight; $this->destination = $destination; } public function visitStandard(ShippingMethod $method): float { return $this->weight * 100; } public function visitExpress(ShippingMethod $method): float { return $this->weight * 250; } public function visitInternational(ShippingMethod $method): float { $baseRate = $this->weight * 350; return match($this->destination) { 'EU' => $baseRate * 1.1, 'Asia' => $baseRate * 1.2, 'Americas' => $baseRate * 1.3, default => $baseRate * 1.5, }; } } // 使用例 $method = ShippingMethod::EXPRESS; $calculator = new ShippingCostCalculator(2.5, 'Asia'); $cost = $method->accept($calculator); // $625
  3. コマンドパターン Enumをコマンドオブジェクトとして使用します: enum UserCommand: string { case ACTIVATE = 'activate'; case DEACTIVATE = 'deactivate'; case RESET_PASSWORD = 'reset_password'; case CHANGE_ROLE = 'change_role'; public function execute(User $user, array $params = []): void { match($this) { self::ACTIVATE => $user->activate(), self::DEACTIVATE => $user->deactivate(), self::RESET_PASSWORD => $user->resetPassword( $params['password'] ?? $this->generateRandomPassword() ), self::CHANGE_ROLE => $user->changeRole($params['role']), }; } private function generateRandomPassword(): string { return bin2hex(random_bytes(8)); } // 権限チェック public function canExecute(User $currentUser, User $targetUser): bool { return match($this) { self::ACTIVATE, self::DEACTIVATE, self::CHANGE_ROLE => $currentUser->isAdmin() || $currentUser->id === $targetUser->id, self::RESET_PASSWORD => $currentUser->isAdmin() || $currentUser->id === $targetUser->id, }; } } // 使用例 $command = UserCommand::RESET_PASSWORD; if ($command->canExecute($currentUser, $targetUser)) { $command->execute($targetUser); echo "パスワードがリセットされました"; } else { echo "この操作を実行する権限がありません"; }

エンタープライズ開発におけるEnum

エンタープライズレベルの開発では、チーム間の連携や長期保守を考慮したEnum活用が重要です:

  1. ドキュメント生成の自動化 PHPDocコメントとEnumを連携させ、APIドキュメントを自動生成します: /** * 支払い処理の結果ステータス * * @api */ enum PaymentResultStatus: string { /** * 支払いが成功しました。トランザクションは完了しています。 */ case SUCCESS = 'success'; /** * 支払い処理中です。結果は非同期で通知されます。 */ case PENDING = 'pending'; /** * 支払いに失敗しました。詳細はエラーコードを参照してください。 */ case FAILED = 'failed'; /** * 支払いがユーザーによってキャンセルされました。 */ case CANCELLED = 'cancelled'; /** * ステータスの表示名を取得します。 * * @return string ユーザー向け表示名 */ public function getDisplayName(): string { return match($this) { self::SUCCESS => '支払い完了', self::PENDING => '処理中', self::FAILED => '支払い失敗', self::CANCELLED => 'キャンセル済み', }; } }
  2. マイクロサービス間の通信 サービス間APIでEnumを活用します: enum ApiErrorCode: string { case VALIDATION_ERROR = 'validation_error'; case AUTHENTICATION_FAILED = 'authentication_failed'; case RESOURCE_NOT_FOUND = 'resource_not_found'; case RATE_LIMIT_EXCEEDED = 'rate_limit_exceeded'; case INTERNAL_ERROR = 'internal_error'; // HTTPステータスコードとマッピング public function getHttpStatusCode(): int { return match($this) { self::VALIDATION_ERROR => 422, self::AUTHENTICATION_FAILED => 401, self::RESOURCE_NOT_FOUND => 404, self::RATE_LIMIT_EXCEEDED => 429, self::INTERNAL_ERROR => 500, }; } // OpenAPI仕様に合わせたエラーレスポンス生成 public function toApiResponse(string $message, ?array $details = null): array { return [ 'error' => [ 'code' => $this->value, 'message' => $message, 'details' => $details, ] ]; } }
  3. バージョニング対応 APIの進化に合わせたEnum定義の管理: /** * @deprecated API v2.0から非推奨。PaymentTypeを使用してください。 */ enum LegacyPaymentMethod: string { case CREDIT = 'credit'; case BANK = 'bank'; case PAYPAL = 'paypal'; // 新しいEnumへの変換 public function toNewType(): PaymentType { return match($this) { self::CREDIT => PaymentType::CREDIT_CARD, self::BANK => PaymentType::BANK_TRANSFER, self::PAYPAL => PaymentType::DIGITAL_WALLET, }; } } // 新しいバージョンのEnum enum PaymentType: string { case CREDIT_CARD = 'credit_card'; case BANK_TRANSFER = 'bank_transfer'; case DIGITAL_WALLET = 'digital_wallet'; case CRYPTOCURRENCY = 'cryptocurrency'; // 旧バージョンからの変換 public static function fromLegacy(LegacyPaymentMethod $legacy): self { return $legacy->toNewType(); } }

PHP Enumを本格的に活用することで、コードベースの表現力、保守性、型安全性が大きく向上します。基本的な使い方から始めて、徐々に発展的なパターンを取り入れることで、より価値の高いコードを作成できるでしょう。

テストとドキュメント:Enumを含むコードの品質管理

PHP Enumを活用したコードの品質を確保するには、適切なテストとドキュメント戦略が不可欠です。Enumの型安全性のメリットを最大限に活かしながら、堅牢なコードを実現するためのテクニックを見ていきましょう。

PHPUnitでのEnum型テスト

PHPUnitを使用したEnum型のテスト例から始めます:

use PHPUnit\Framework\TestCase;

class PaymentMethodTest extends TestCase
{
    public function testEnumValues(): void
    {
        // 値の確認
        $this->assertSame('credit_card', PaymentMethod::CREDIT_CARD->value);
        $this->assertSame('bank_transfer', PaymentMethod::BANK_TRANSFER->value);
        
        // caseメソッドの結果確認
        $this->assertCount(4, PaymentMethod::cases());
        
        // fromメソッドのテスト
        $this->assertEquals(
            PaymentMethod::PAYPAL,
            PaymentMethod::from('paypal')
        );
        
        // tryFromメソッドのテスト
        $this->assertEquals(
            PaymentMethod::CRYPTOCURRENCY,
            PaymentMethod::tryFrom('crypto')
        );
        $this->assertNull(PaymentMethod::tryFrom('invalid_method'));
        
        // fromメソッドの例外テスト
        $this->expectException(\ValueError::class);
        PaymentMethod::from('invalid_method');
    }
    
    public function testEnumMethods(): void
    {
        // メソッドのテスト
        $this->assertEquals('クレジットカード', PaymentMethod::CREDIT_CARD->getLabel());
        $this->assertTrue(PaymentMethod::PAYPAL->isOnlinePayment());
        $this->assertFalse(PaymentMethod::BANK_TRANSFER->isOnlinePayment());
        
        // staticメソッドのテスト
        $options = PaymentMethod::getOptions();
        $this->assertArrayHasKey('credit_card', $options);
        $this->assertEquals('クレジットカード', $options['credit_card']);
    }
    
    /**
     * @dataProvider paymentMethodsProvider
     */
    public function testPaymentProcessing(PaymentMethod $method, bool $expectSuccess): void
    {
        $processor = new PaymentProcessor();
        
        if ($expectSuccess) {
            $this->assertTrue($processor->process($method, 100.00));
        } else {
            $this->expectException(PaymentException::class);
            $processor->process($method, 100.00);
        }
    }
    
    public function paymentMethodsProvider(): array
    {
        return [
            'クレジットカード決済は成功する' => [PaymentMethod::CREDIT_CARD, true],
            'PayPal決済は成功する' => [PaymentMethod::PAYPAL, true],
            '銀行振込は処理できない' => [PaymentMethod::BANK_TRANSFER, false],
            // テスト中は暗号通貨決済は無効
            '暗号通貨決済は失敗する' => [PaymentMethod::CRYPTOCURRENCY, false],
        ];
    }
}

カスタムアサーションの実装

Enumに特化したカスタムアサーションを追加すると、テストの可読性が向上します:

trait EnumAssertions
{
    public static function assertEnumEquals(
        \UnitEnum $expected,
        \UnitEnum $actual,
        string $message = ''
    ): void {
        self::assertSame(
            $expected,
            $actual,
            $message ?: sprintf(
                'Enum値が一致しません。期待値: %s, 実際の値: %s',
                self::formatEnum($expected),
                self::formatEnum($actual)
            )
        );
    }
    
    public static function assertEnumValueEquals(
        string|int $expected,
        \BackedEnum $actual,
        string $message = ''
    ): void {
        self::assertSame(
            $expected,
            $actual->value,
            $message ?: sprintf(
                'Enum値が一致しません。期待値: %s, 実際の値: %s',
                $expected,
                $actual->value
            )
        );
    }
    
    public static function assertEnumIsOneOf(
        array $expectedEnums,
        \UnitEnum $actual,
        string $message = ''
    ): void {
        $found = false;
        foreach ($expectedEnums as $expected) {
            if ($expected === $actual) {
                $found = true;
                break;
            }
        }
        
        self::assertTrue(
            $found,
            $message ?: sprintf(
                'Enum値 %s は許容される値のセットに含まれていません',
                self::formatEnum($actual)
            )
        );
    }
    
    private static function formatEnum(\UnitEnum $enum): string
    {
        if ($enum instanceof \BackedEnum) {
            return sprintf('%s::%s(%s)', get_class($enum), $enum->name, $enum->value);
        }
        
        return sprintf('%s::%s', get_class($enum), $enum->name);
    }
}

// 使用例
class OrderStatusTest extends TestCase
{
    use EnumAssertions;
    
    public function testOrderFlow(): void
    {
        $order = new Order();
        $this->assertEnumEquals(OrderStatus::PENDING, $order->getStatus());
        
        $order->process();
        $this->assertEnumIsOneOf(
            [OrderStatus::PROCESSING, OrderStatus::ON_HOLD],
            $order->getStatus()
        );
        
        // Backed Enumの値テスト
        $this->assertEnumValueEquals('processing', $order->getStatus());
    }
}

モックとスタブでのEnum活用

テスト中のモックオブジェクトでEnumを活用する例:

public function testOrderStatusNotification(): void
{
    // 通知サービスのモック
    $notificationService = $this->createMock(NotificationService::class);
    
    // Enumパラメータを含むメソッド呼び出しの期待設定
    $notificationService->expects($this->once())
        ->method('sendStatusUpdate')
        ->with(
            $this->anything(), // 任意のオーダーID
            $this->equalTo(OrderStatus::SHIPPED) // 特定のEnum値を期待
        )
        ->willReturn(true);
    
    $orderProcessor = new OrderProcessor($notificationService);
    $result = $orderProcessor->markAsShipped(1001);
    
    $this->assertTrue($result);
}

パラメタライズドテスト

データプロバイダーを使用したEnumテスト:

/**
 * @dataProvider statusTransitionsProvider
 */
public function testOrderStatusTransitions(
    OrderStatus $initialStatus,
    OrderStatus $targetStatus,
    bool $shouldBeAllowed
): void {
    $order = new Order();
    $order->setStatus($initialStatus);
    
    if (!$shouldBeAllowed) {
        $this->expectException(InvalidStatusTransitionException::class);
    }
    
    $order->transitionTo($targetStatus);
    
    if ($shouldBeAllowed) {
        $this->assertEnumEquals($targetStatus, $order->getStatus());
    }
}

public function statusTransitionsProvider(): array
{
    return [
        '保留から処理中への遷移は許可' => [
            OrderStatus::PENDING, OrderStatus::PROCESSING, true
        ],
        '保留からキャンセルへの遷移は許可' => [
            OrderStatus::PENDING, OrderStatus::CANCELLED, true
        ],
        '保留から発送済みへの遷移は禁止' => [
            OrderStatus::PENDING, OrderStatus::SHIPPED, false
        ],
        '処理中から発送済みへの遷移は許可' => [
            OrderStatus::PROCESSING, OrderStatus::SHIPPED, true
        ],
        '発送済みから処理中への遷移は禁止' => [
            OrderStatus::SHIPPED, OrderStatus::PROCESSING, false
        ],
        'キャンセル済みから任意の状態への遷移は禁止' => [
            OrderStatus::CANCELLED, OrderStatus::PENDING, false
        ],
    ];
}

統合テストでのEnum使用

API応答などを検証する統合テストでのEnum活用例:

class OrderApiTest extends WebTestCase
{
    public function testCreateOrder(): void
    {
        $client = static::createClient();
        
        // APIリクエスト送信
        $client->request('POST', '/api/orders', [], [], [], json_encode([
            'customer_id' => 123,
            'items' => [['product_id' => 456, 'quantity' => 2]],
            'payment_method' => PaymentMethod::CREDIT_CARD->value
        ]));
        
        $response = $client->getResponse();
        $data = json_decode($response->getContent(), true);
        
        // ステータスコードと応答内容の検証
        $this->assertResponseIsSuccessful();
        $this->assertArrayHasKey('order_id', $data);
        $this->assertEquals(OrderStatus::PENDING->value, $data['status']);
    }
}

PHPDoc標準とEnumドキュメント

Enumのドキュメント生成を効果的に行うためのPHPDocの書き方:

/**
 * 注文のステータスを表すEnum
 *
 * このEnumは注文のライフサイクル全体を表現し、
 * 各ステータス間の遷移ルールを定義します。
 *
 * @api
 * @since 2.0.0
 */
enum OrderStatus: string
{
    /**
     * 注文が作成され、支払い待ちの状態
     *
     * この状態では、注文はまだ確定していません。
     * ユーザーは注文内容を変更したりキャンセルしたりできます。
     */
    case PENDING = 'pending';
    
    /**
     * 支払いが確認され、注文処理中の状態
     *
     * 商品のピッキングや梱包などの作業が行われている状態です。
     * この段階からは注文内容の変更はできません。
     */
    case PROCESSING = 'processing';
    
    /**
     * 注文商品が発送された状態
     *
     * 配送業者に商品が引き渡され、配送中の状態です。
     * 追跡番号が生成されます。
     */
    case SHIPPED = 'shipped';
    
    /**
     * 商品が顧客に配達された状態
     *
     * 配送業者によって商品が顧客に届けられた状態です。
     */
    case DELIVERED = 'delivered';
    
    /**
     * 注文がキャンセルされた状態
     *
     * 顧客または管理者によって注文がキャンセルされました。
     * この状態は最終状態であり、他の状態に変更できません。
     */
    case CANCELLED = 'cancelled';
    
    /**
     * 次に可能なステータスの一覧を取得
     *
     * 現在のステータスから遷移可能な次のステータスのリストを返します。
     *
     * @return array<OrderStatus> 遷移可能なステータスの配列
     */
    public function getNextPossibleStatuses(): array
    {
        // 実装内容...
    }
}

このようなPHPDoc形式のコメントを追加することで、IDEの補完機能が強化されるだけでなく、自動ドキュメント生成ツールを使って詳細なAPIドキュメントを生成できます。

Swagger/OpenAPIとの連携

RESTful APIでEnumを使用する場合、OpenAPI仕様に連携する例:

/**
 * @OA\Schema(
 *     schema="OrderStatus",
 *     type="string",
 *     description="注文のステータス",
 *     enum={"pending", "processing", "shipped", "delivered", "cancelled"}
 * )
 */
enum OrderStatus: string
{
    // 実装...
}

/**
 * @OA\Get(
 *     path="/orders/{id}",
 *     summary="注文情報を取得",
 *     @OA\Parameter(
 *         name="id",
 *         in="path",
 *         required=true,
 *         description="注文ID",
 *         @OA\Schema(type="integer")
 *     ),
 *     @OA\Response(
 *         response=200,
 *         description="成功",
 *         @OA\JsonContent(
 *             @OA\Property(property="id", type="integer", example=1234),
 *             @OA\Property(property="status", ref="#/components/schemas/OrderStatus"),
 *             @OA\Property(property="created_at", type="string", format="date-time")
 *         )
 *     )
 * )
 */
public function getOrder(int $id): JsonResponse
{
    // 実装...
}

静的解析ツールによる品質保証

PHPStanやPsalmなどの静的解析ツールは、Enumを含むコードの品質向上に役立ちます:

// PHPStan level 8 の設定例
// phpstan.neon
parameters:
    level: 8
    paths:
        - src
    checkMissingIterableValueType: true
    checkGenericClassInNonGenericObjectType: true
    checkImplicitMixed: true

// Psalm の設定例
// psalm.xml
<?xml version="1.0"?>
<psalm
    errorLevel="2"
    resolveFromConfigFile="true"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns="https://getpsalm.org/schema/config"
    xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
>
    <projectFiles>
        <directory name="src" />
        <ignoreFiles>
            <directory name="vendor" />
        </ignoreFiles>
    </projectFiles>
</psalm>

PHPStanでのEnum特有のチェック例:

// PHPStanカスタムルール例
// src/PHPStan/Rules/Enum/EnumValueExistsRule.php
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;

class EnumValueExistsRule implements Rule
{
    public function getNodeType(): string
    {
        return Node\Expr\StaticCall::class;
    }
    
    public function processNode(Node $node, Scope $scope): array
    {
        if (!$node instanceof Node\Expr\StaticCall) {
            return [];
        }
        
        // Enumのfrom/tryFromメソッド呼び出しをチェック
        if (!$node->name instanceof Node\Identifier) {
            return [];
        }
        
        if (!in_array($node->name->name, ['from', 'tryFrom'], true)) {
            return [];
        }
        
        // 呼び出し先のクラスがEnum型かチェック
        $calledOnType = $scope->getType($node->class);
        if (!$calledOnType->hasMethod($node->name->name)->yes()) {
            return [];
        }
        
        $enumClassReflection = $calledOnType->getObjectClassReflections()[0] ?? null;
        if ($enumClassReflection === null || !$enumClassReflection->isEnum()) {
            return [];
        }
        
        // 引数が文字列リテラルで、有効なEnum値かチェック
        if (count($node->args) < 1) {
            return [];
        }
        
        $argType = $scope->getType($node->args[0]->value);
        if (!$argType->isConstantString()->yes()) {
            return [];
        }
        
        $argValue = $argType->getConstantStrings()[0]->getValue();
        
        $enumValues = [];
        foreach ($enumClassReflection->getNativeReflection()->getCases() as $case) {
            $caseValue = $case->getBackingValue();
            if ($caseValue !== null) {
                $enumValues[] = $caseValue;
            }
        }
        
        if (!in_array($argValue, $enumValues, true)) {
            return [
                sprintf(
                    'Call to %s::from() with value "%s" will throw a ValueError because the value is not a valid case backing value.',
                    $enumClassReflection->getDisplayName(),
                    $argValue
                )
            ];
        }
        
        return [];
    }
}

CI/CDパイプラインでの統合

GitHub Actions等のCI/CDパイプラインでEnumを含むコードの品質をチェックする例:

# .github/workflows/quality.yml
name: Code Quality

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  quality:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Setup PHP
      uses: shivammathur/setup-php@v2
      with:
        php-version: '8.1'
        extensions: mbstring, intl
        coverage: pcov
        
    - name: Install dependencies
      run: composer install --prefer-dist --no-progress
        
    - name: Run PHPStan
      run: vendor/bin/phpstan analyse
      
    - name: Run PHPCS
      run: vendor/bin/phpcs
      
    - name: Run PHPUnit
      run: vendor/bin/phpunit --coverage-clover=coverage.xml
      
    - name: Upload coverage to Codecov
      uses: codecov/codecov-action@v2
      with:
        files: ./coverage.xml

テスト戦略のベストプラクティス

Enumを含むコードのテスト戦略には、以下のポイントを押さえましょう:

  1. 網羅的なテスト
    • 全てのEnum値について個別にテスト
    • 境界条件とエッジケースの重点的検証
    • 不正な値に対する動作確認
  2. メソッドテストの徹底
    • Enumに追加したメソッドのテスト
    • 戻り値の型と内容の検証
    • パフォーマンス検証(必要に応じて)
  3. 統合シナリオのテスト
    • 実際のユースケースに基づくシナリオテスト
    • 状態遷移の一連のフローテスト
    • システム全体での動作確認
  4. 回帰テストの自動化
    • リファクタリング時の回帰テスト
    • CI/CDパイプラインでの自動テスト
    • コードカバレッジの定期的なチェック

ドキュメント管理のベストプラクティス

Enumを活用したコードを効果的にドキュメント化するポイント:

  1. 自己文書化コード
    • 明確なEnum命名規則
    • メソッド名と引数の命名にドメイン用語を使用
    • コメントよりコードの明確さを優先
  2. PHPDocの徹底
    • 全てのEnumケースに簡潔な説明
    • メソッドの引数と戻り値の型情報
    • 例外発生条件の明示
  3. 使用例の提供
    • 典型的な使用パターンのサンプルコード
    • 実際のユースケースに基づくシナリオ例
    • よくある間違いと回避策
  4. チームナレッジの共有
    • 設計意図とパターンの説明
    • リファクタリング履歴とEnumへの移行理由
    • ベストプラクティスと注意点の共有

Enumを含むコードの品質管理は、テストとドキュメントを通じて堅牢性と保守性を高めるプロセスです。型安全性というEnumの特性を活かしながら、継続的に品質を向上させることが重要です。適切なテスト戦略とドキュメント化により、チーム全体がEnumの恩恵を最大限に受けることができるでしょう。

まとめ:PHP Enumで実現する型安全で堅牢なコード設計

PHP 8.1で導入されたEnum型は、PHPの型システムにおける重要なマイルストーンです。この記事を通じて見てきたように、Enumを活用することで、より型安全で堅牢なコード設計が可能になります。

Enumが提供する主要なメリット

  1. 型安全性の向上
    • コンパイル時のエラー検出
    • 不正な値の混入防止
    • IDE補完とリファクタリングサポート
  2. ドメインロジックのカプセル化
    • 値と振る舞いの結合
    • ビジネスルールの集約
    • 関連する概念のグループ化
  3. コードの明確さと表現力
    • 自己文書化コード
    • ドメイン用語の直接的な表現
    • 意図の明確な伝達
  4. 保守性と拡張性の向上
    • 変更の局所化
    • 統一された変更ポイント
    • 一貫したインターフェース

実践での活用シーン

本記事では、以下の実践シーンでEnumを活用する方法を見てきました:

  1. ステータス管理とフロー制御:注文状態や承認フローなど、状態遷移を型安全に管理できます。
  2. バリデーションとデータ整合性:入力値の検証から永続化までの一貫した型チェックを実現します。
  3. ポリシーとパーミッション管理:権限制御を明確かつ柔軟に設計できます。
  4. 値オブジェクトとドメイン駆動設計:ドメインの概念をより正確にコードで表現できます。
  5. APIレスポンスとエラーハンドリング:一貫性のあるAPIインターフェースを構築できます。

これらの活用シーンは、それぞれが独立しているわけではなく、相互に組み合わせることでさらに大きな価値を生み出します。例えば、ステータス管理とAPIレスポンスを組み合わせることで、一貫性のあるRESTful APIを簡単に実装できます。

フレームワークとの連携

現代のPHP開発では、LaravelやSymfonyなどのフレームワークが広く使われています。これらのフレームワークはEnumに対する包括的なサポートを提供しており、以下の連携が可能です:

  • Laravel:Eloquentモデルへのキャスト、バリデーション、マイグレーションとの統合
  • Symfony:FormType、Doctrine ORM、バリデーション制約との連携

フレームワークの機能とEnumを組み合わせることで、より効率的な開発が可能になります。

Enumを超えて:PHP型システムの進化

PHP Enumは、PHP言語の型システム強化の一環として導入されました。PHP 7からPHP 8にかけての進化を見ると、以下のような型関連機能が追加されています:

  • PHP 7.0:スカラー型宣言、戻り値の型宣言
  • PHP 7.1:nullable型、void戻り値型
  • PHP 7.4:プロパティ型宣言
  • PHP 8.0:Union型、コンストラクタプロパティプロモーション
  • PHP 8.1:Enum型、readonly プロパティ、intersection型

このようなPHPの型システム強化の流れは、今後も続くと予想されます。Enumを効果的に活用することで、この進化の恩恵を最大限に受けることができます。

開発プロセスへの統合

Enumを開発プロセス全体に統合することで、さらなる価値を引き出せます:

  1. 設計フェーズ:ドメインモデリング時にEnum型を活用して、概念を明確化
  2. 実装フェーズ:型安全な実装による不具合の早期発見
  3. テストフェーズ:テストケースの明確化と網羅性の向上
  4. リファクタリング:安全なコード変更とIDE支援の活用
  5. 保守フェーズ:意図の明確なコードによる理解促進と変更の容易さ

次のステップ

PHP Enumをマスターするための次のステップとして、以下のアクションをお勧めします:

  1. 既存コードの見直し:現在の定数やマジックナンバーをEnum化する候補を特定
  2. 小規模な適用から始める:重要度の高い一部のドメイン概念からEnum化を開始
  3. テストの充実:Enumを含むコードのユニットテストとケース網羅を強化
  4. チーム内での標準化:Enum命名規則や使用パターンのガイドラインを策定
  5. 継続的な学習:PHP言語の進化と共にEnum活用法も更新していく

おわりに

PHP Enumは、単なる言語機能ではなく、より品質の高いソフトウェア設計を実現するための強力なツールです。型安全性、ドメイン表現力、保守性の向上など、多くのメリットをもたらします。

本記事で紹介した実践例やテクニックを活用し、PHP Enumの可能性を最大限に引き出してください。適切なEnumの活用は、コードの品質向上だけでなく、開発効率の向上やビジネス価値の迅速な提供にも繋がります。

最後に、PHP Enumは比較的新しい機能ですが、その価値と可能性は計り知れません。エンジニアとして、新しい技術に積極的に取り組み、常に進化し続けることが重要です。PHP Enumを起点に、より良いソフトウェア設計の旅を始めましょう。