1. ホーム
  2. PHP

【PHP】enum(列挙型)の使い方|定数の集まりを型安全に扱う方法

Share

記事の状態を 'draft' や 'published' といった文字列で持ち回っていると、どこかで 'drafft' と打ち間違えても PHP は何も教えてくれません。const STATUS_DRAFT = 'draft'; のようなクラス定数にしても、引数の型は結局 string のままなので、任意の文字列が通ってしまいます。ここを解決するのが PHP 8.1 で追加された enum(列挙型)です。取りうる値そのものが型になるので、想定外の値は関数を呼んだ時点で弾けます。この記事では、enum の基本の書き方から Backed Enum、cases()・from()・tryFrom()、メソッドやインターフェイスの実装、そして enum ならではの制約までを解説します。

文字列やクラス定数で状態を表すと何が起きるか

まず、enum が無かったころのよくある書き方を見てみます。決まった値の集まりを表すときは、クラス定数を並べるのが定番でした。

old-style.php
<?php
class Status
{
    const DRAFT     = 'draft';
    const PUBLISHED = 'published';
    const ARCHIVED  = 'archived';
}

function publish(string $status): void
{
    echo "受け取った: {$status}" . PHP_EOL;
}

publish(Status::DRAFT);   // 受け取った: draft
publish('drafft');        // 受け取った: drafft ← タイポなのに通ってしまう
publish('こんにちは');     // これも通ってしまう

定数を使う分にはタイポしても未定義定数のエラーになりますが、引数の型が string である以上、定数を経由しない生の文字列はすべて素通りします。実際のコードではフォームの入力値やデータベースから取ってきた値をそのまま渡すことが多く、そこにおかしな値が混ざっていても関数の中まで気付けません。結果として、if や switch のどの条件にも当てはまらず、原因の分かりにくいバグになります。

enum を使うと、この「取りうる値」そのものが1つの型になります。Status という型を引数に指定すれば、Status のケース以外は PHP が受け付けません。値の妥当性チェックを自分で書かなくても、型宣言だけで不正な値を入口で止められるわけです。

enum の基本 — case を並べるだけで型になる

もっとも単純な enum は、enum キーワードの中に case を並べるだけです。値を持たないこの形を純粋な列挙型(Pure Enum)と呼びます。

Status.php
<?php
enum Status
{
    case Draft;
    case Published;
    case Archived;
}

各ケースには Status::Draft のように、クラス定数と同じ :: でアクセスします。取り出したものは Status 型のオブジェクトで、同じケースなら常に同一のインスタンスになるため、比較には === がそのまま使えます。

index.php
<?php
$status = Status::Draft;

var_dump($status);                       // enum(Status::Draft)
var_dump($status->name);                 // string(5) "Draft"
var_dump($status === Status::Draft);     // bool(true)
var_dump($status instanceof Status);     // bool(true)

echo match ($status) {
    Status::Draft     => '下書きです',
    Status::Published => '公開中です',
    Status::Archived  => 'アーカイブです',
}, PHP_EOL;
// 下書きです

->name は、そのケースの名前を文字列で返す読み取り専用のプロパティです。Status::Draft->name なら 'Draft' になります。すべての enum のケースが持っているので、Pure Enum でもログ出力やデバッグに使えます。

enum とセットでよく使うのが match 式です。ケースは同一インスタンスなので、match の厳密比較(===)ときれいにかみ合います。上の例のようにすべてのケースを書いておけば、あとから case を追加したときに UnhandledMatchError が出て、対応漏れに気付けるという利点もあります。

引数と戻り値の型に enum を使う

enum のいちばんの価値は、そのまま型宣言に書けることです。クラスやインターフェイスと同じように、引数や戻り値、プロパティの型として Status と書けます。

publish.php
<?php
function publish(Status $status): void
{
    echo $status->name . PHP_EOL;
}

publish(Status::Published);   // Published

publish('published');
// Fatal error: Uncaught TypeError: publish(): Argument #1 ($status)
// must be of type Status, string given

文字列を渡すと TypeError になります。関数の中に入る前に、値が3つのケースのいずれかであることが保証されるので、関数側で「知らない値だったらどうするか」を考えなくて済みます。match にすべてのケースを書いておけば、それ以外の状態は存在しないと言い切れるわけです。

Backed Enum で case に値を持たせる

実際のアプリケーションでは、状態をデータベースの列や JSON に保存する必要があります。そのときに使うのが、ケースにスカラー値を紐付けた Backed Enum(値に依存した列挙型)です。enum 名前: 型 のように backing type を書き、各 case に値を代入します。指定できる型は int か string のどちらかだけです。

Status.php
<?php
enum Status: string
{
    case Draft     = 'draft';
    case Published = 'published';
    case Archived  = 'archived';
}

var_dump(Status::Draft->name);    // string(5) "Draft"
var_dump(Status::Draft->value);   // string(5) "draft"

Backed Enum のケースには ->value が増えます。これも読み取り専用で、case に書いた値がそのまま入っています。保存するときはこの ->value を書き込み、読み込むときは次に説明する from() / tryFrom() でケースに戻す、というのが基本の流れです。

from() と tryFrom() の違い

値からケースを取得する静的メソッドは2つあり、見つからなかったときの動きだけが違います。from() は ValueError を投げ、tryFrom() は null を返します。

from-tryfrom.php
<?php
var_dump(Status::from('published'));      // enum(Status::Published)
var_dump(Status::tryFrom('published'));   // enum(Status::Published)

var_dump(Status::tryFrom('deleted'));     // NULL

Status::from('deleted');
// Fatal error: Uncaught ValueError:
// "deleted" is not a valid backing value for enum Status

使い分けの目安は、その値が必ず正しいはずなのかどうかです。自分たちのデータベースに保存した値のように、不正な値が入っていたらそれ自体が異常事態というケースでは from() を使い、例外で気付けるようにします。一方、ユーザーが送ってきたフォームの値や URL のクエリパラメータのように信用できない入力には tryFrom() を使い、null が返ってきたらエラー表示や既定値へのフォールバックに回します。

データベースやフォームの値と往復させる

実際の往復はこんな形になります。書き込むときは ->value、読み出すときは from() です。

repository.php
<?php
// 保存する:enum → 文字列
$stmt = $pdo->prepare('UPDATE posts SET status = ? WHERE id = ?');
$stmt->execute([Status::Published->value, $id]);

// 読み出す:文字列 → enum
$row = $pdo->query('SELECT status FROM posts WHERE id = 1')->fetch();
$status = Status::from($row['status']);   // 想定外の値なら ValueError

// フォームの入力は信用できないので tryFrom + 既定値
$input  = $_GET['status'] ?? '';
$filter = Status::tryFrom($input) ?? Status::Published;

echo $filter->name . PHP_EOL;

tryFrom() と null 合体演算子(??)を組み合わせると、「正しい値ならそれを使い、そうでなければ既定値」という処理が1行で書けます。ここを通ったあとの変数は必ず Status 型なので、以降のコードでは文字列の検証をいっさい気にせずに済みます。信用できない値を enum に変換する境界を1か所に集めるのが、enum を使ううえでの基本方針です。

cases() で全ケースを取り出す

静的メソッドの cases() は、その enum が持つすべてのケースを宣言した順番の配列で返します。セレクトボックスやラジオボタンの選択肢を組み立てるときに便利です。

select.php
<?php
$current = Status::Published;   // 現在選ばれている状態

echo '<select name="status">' . PHP_EOL;

foreach (Status::cases() as $case) {
    printf(
        '  <option value="%s"%s>%s</option>' . PHP_EOL,
        htmlspecialchars($case->value),
        $case === $current ? ' selected' : '',
        htmlspecialchars($case->name)
    );
}

echo '</select>' . PHP_EOL;

選択肢を増やしたいときは enum に case を1行足すだけで、フォームの表示も match の分岐も自動的に追随します。「取りうる値の一覧」が enum の宣言1か所に集まるのは、定数を並べていたころには無かった大きな利点です。

なお cases() は Pure Enum でも使えます。返ってくるのはケースのオブジェクトの配列なので、値の配列が欲しいときは array_column(Status::cases(), 'value') のように取り出します。

enum で使える機能の一覧

ここまでに出てきたプロパティとメソッドを整理します。Pure Enum と Backed Enum で使えるものが違う点に注意してください。

記述説明Pure Enum
->nameケース名を文字列で返す読み取り専用プロパティ。Status::Draft->name なら 'Draft'使える
->valuecase に紐付けた int または string の値を返す読み取り専用プロパティ使えない
::cases()宣言順に全ケースを配列で返す静的メソッド使える
::from($value)値に対応するケースを返す。存在しない値なら ValueError使えない
::tryFrom($value)値に対応するケースを返す。存在しない値なら null使えない

この違いは、内部で実装されているインターフェイスの差そのものです。すべての enum のケースは UnitEnum を実装していて ->name と ::cases() を持ち、Backed Enum はさらに BackedEnum を実装して ->value と ::from() / ::tryFrom() が加わります。どちらか判断したいときは $case instanceof BackedEnum で確かめられます。

enum にメソッドと定数を定義する

enum はクラスと同じようにメソッドを持てます。$this は「今のケース」を指すので、match ($this) でケースごとに違う値を返すメソッドが自然に書けます。表示用のラベルを返す label() は、その典型例です。

Status.php
<?php
enum Status: string
{
    case Draft     = 'draft';
    case Published = 'published';
    case Archived  = 'archived';

    // 既定値を定数として持つ(値にケース自身を書ける)
    const DEFAULT = self::Draft;

    public function label(): string
    {
        return match ($this) {
            Status::Draft     => '下書き',
            Status::Published => '公開中',
            Status::Archived  => 'アーカイブ',
        };
    }

    public function isVisible(): bool
    {
        return $this === Status::Published;
    }

    // 静的メソッドも定義できる
    public static function fromLabel(string $label): self
    {
        foreach (self::cases() as $case) {
            if ($case->label() === $label) {
                return $case;
            }
        }
        throw new ValueError("該当するラベルがありません: {$label}");
    }
}
index.php
<?php
echo Status::Draft->label() . PHP_EOL;              // 下書き
var_dump(Status::Published->isVisible());           // bool(true)
var_dump(Status::DEFAULT === Status::Draft);        // bool(true)
var_dump(Status::fromLabel('公開中'));              // enum(Status::Published)

ポイントは、ラベルの対応表が enum の中に閉じ込められることです。テンプレート側に if ($status === 'draft') echo '下書き'; のような分岐を散らばらせずに済みますし、case を追加したときも match がエラーで教えてくれるので、書き換え漏れが起きにくくなります。

定数も普通に定義できます。上の const DEFAULT = self::Draft; のように、定数の値としてケース自身を指定できるのが便利なところで、「既定の状態」を enum の中で表現できます。ここで const ではなく case を使うと新しいケースが増えてしまうので、「ケースの別名」がほしいときは必ず const にします。

インターフェイスを実装して共通の型として扱う

enum はインターフェイスを実装できます。書き方はクラスと同じで、Backed Enum なら enum Status: string implements HasLabel のように backing type のあとに implements を続けます。

HasLabel.php
<?php
interface HasLabel
{
    public function label(): string;
}

enum Status: string implements HasLabel
{
    case Draft     = 'draft';
    case Published = 'published';

    public function label(): string
    {
        return match ($this) {
            Status::Draft     => '下書き',
            Status::Published => '公開中',
        };
    }
}

enum Priority: int implements HasLabel
{
    case Low  = 1;
    case High = 2;

    public function label(): string
    {
        return match ($this) {
            Priority::Low  => '低',
            Priority::High => '高',
        };
    }
}

こうしておくと、種類の違う enum を1つの型でまとめて扱えます。ラベル付きの選択肢を描画する処理を、enum ごとに書き分けずに済みます。

render.php
<?php
// HasLabel を実装した enum なら何でも受け取れる
function renderOptions(string $enumClass): void
{
    foreach ($enumClass::cases() as $case) {
        if (!$case instanceof HasLabel) {
            continue;
        }
        printf(
            '<option value="%s">%s</option>' . PHP_EOL,
            htmlspecialchars((string) $case->value),
            htmlspecialchars($case->label())
        );
    }
}

renderOptions(Status::class);
renderOptions(Priority::class);

個々のケースを引数として受け取る場合も、function describe(HasLabel $item): string のようにインターフェイス名で型を宣言できます。enum のケースは通常のオブジェクトなので、instanceof による判定も期待どおりに動きます。

enum を使うときにエラーになりやすいところ

enum はクラスに似ていますが、「値そのものを表す型」という性質のためにいくつかの制限があります。つまずきやすい箇所を原因ごとに見ていきます。

Pure Enum には ->value も from() も無い

もっとも多いのがこれです。->value・::from()・::tryFrom() は Backed Enum 専用で、値を持たない Pure Enum では使えません。

pure-error.php
<?php
enum Status
{
    case Draft;
}

var_dump(Status::Draft->value);
// Warning: Undefined property: Status::$value
// NULL

Status::tryFrom('draft');
// Fatal error: Uncaught Error:
// Call to undefined method Status::tryFrom()

->value のほうは致命的なエラーにならず、警告と null で済んでしまう点が厄介です。データベースに null が保存されてから気付くこともあるので、永続化や JSON 化の予定が少しでもあるなら、最初から Backed Enum で書いておくのが安全です。逆に、プログラムの中だけで状態を表すのであれば Pure Enum で十分です。

なお backing type に指定できるのは int と string だけです。enum Status: float と書くと Enum backing type must be int or string, float given という致命的なエラーになります。また、backing type を書いたのに case Draft; と値を省略する、あるいは Pure Enum なのに値を書く、といった書き間違いもエラーになります。

new でインスタンス化できず、プロパティも持てない

enum のケースはあらかじめ決まった数だけ存在する定数のようなものなので、あとから増やせません。new Status() は Error になります。

new-error.php
<?php
$status = new Status();
// Fatal error: Uncaught Error: Cannot instantiate enum Status

同じ理由で、enum はプロパティを宣言できません。public string $memo = ''; のような行を書くと Enum Status cannot include properties という致命的なエラーになります。コンストラクタも書けません。

これは制限というより設計の前提です。enum のケースはプログラム全体で1つずつしか存在しない共有のインスタンスなので、もし書き換え可能な状態を持てたら、どこかで変更した値が別の場所に波及してしまいます。ケースごとに固定のデータを紐付けたいなら、プロパティではなく match ($this) を使ったメソッドで表現するのが定石です。前述の label() がまさにその形です。

case の値は重複できず、関数の戻り値も書けない

Backed Enum の値は、ケースを一意に特定するためのものです。したがって同じ値を複数のケースに割り当てることはできません。宣言した時点ではエラーになりませんが、その enum を最初に使った瞬間に Error が投げられます。

duplicate-error.php
<?php
enum Status: string
{
    case Draft   = 'draft';
    case Private = 'draft';   // 同じ値
}

var_dump(Status::Draft);
// Fatal error: Uncaught Error:
// Duplicate value in enum Status for cases Draft and Private

もうひとつ、case に書ける値はコンパイル時に確定する定数式に限られます。リテラルや定数、それらを組み合わせた式は書けますが、関数やメソッドの呼び出しは使えません。case Draft = strtolower('DRAFT'); のように書くと Constant expression contains invalid operations という致命的なエラーになります。翻訳関数を通したラベルを値にしたい、といった要求は、値ではなくメソッドで実現してください。

Pure Enum は json_encode() できない

Backed Enum は特別な対応をしなくても JSON に変換できます。JsonSerializable を実装していなくても、->value の値としてエンコードされます。

json.php
<?php
enum Status: string
{
    case Draft = 'draft';
}

echo json_encode(['status' => Status::Draft]) . PHP_EOL;
// {"status":"draft"}

一方 Pure Enum には JSON で表すべき値がないため、既定の表現が存在しません。json_encode() は false を返して失敗し、json_last_error_msg() は Non-backed enums have no default serialization を返します。JSON_THROW_ON_ERROR を付けていれば同じメッセージの JsonException が投げられます。

json-pure.php
<?php
enum Color
{
    case Red;
}

var_dump(json_encode(['color' => Color::Red]));   // bool(false)
echo json_last_error_msg() . PHP_EOL;
// Non-backed enums have no default serialization

// どうしても Pure Enum を JSON にしたいなら JsonSerializable を実装する
enum Color2 implements JsonSerializable
{
    case Red;
    case Blue;

    public function jsonSerialize(): string
    {
        return $this->name;
    }
}

echo json_encode(['color' => Color2::Red]) . PHP_EOL;
// {"color":"Red"}

API のレスポンスやセッションなど、外に出す可能性のある値は Backed Enum にしておけばこの問題自体が起きません。JsonSerializable でしのぐより、backing type を付けるほうが素直な解決です。

PHP 8.0 以前では使えない

enum は PHP 8.1 で追加された機能です。それより前のバージョンで enum と書くとパースエラーになります。共用サーバーや古いプロジェクトでは PHP 8.0 以前が動いていることもあるので、導入前に php -v や phpversion() でバージョンを確認してください。WordPress のプラグインやテーマのように、動作環境を自分で選べないコードでは特に注意が必要です。

PHP 8.0 以前を相手にする場合は、冒頭に挙げたクラス定数で代替するしかありません。ただし素の定数のままでは不正な値を防げないので、値の検証をひとまとめにしておくと後から enum へ移行しやすくなります。

fallback.php
<?php
// PHP 8.0 以前向けの代替
final class Status
{
    const DRAFT     = 'draft';
    const PUBLISHED = 'published';
    const ARCHIVED  = 'archived';

    /** enum の cases() 相当 */
    public static function all(): array
    {
        return [self::DRAFT, self::PUBLISHED, self::ARCHIVED];
    }

    /** enum の tryFrom() 相当 */
    public static function tryFrom(string $value): ?string
    {
        return in_array($value, self::all(), true) ? $value : null;
    }
}

この形なら値の妥当性チェックは1か所にまとまりますが、引数の型はやはり string のままなので、型による保証は得られません。PHP 8.1 以上に上げられるなら、enum に置き換える価値は十分にあります。

まとめ

enum は PHP 8.1 で追加された、取りうる値の集まりをそのまま型にする仕組みです。enum Status { case Draft; ... } と書けば Pure Enum、enum Status: string { case Draft = 'draft'; ... } と書けば Backed Enum になります。ケースには Status::Draft でアクセスでき、->name でケース名、Backed Enum なら ->value で紐付けた値を取得できます。値からケースに戻すには from()(見つからなければ ValueError)と tryFrom()(見つからなければ null)を、状況に応じて使い分けます。cases() で全ケースを配列で取れるので、選択肢の生成も enum の宣言だけで完結します。

enum にはメソッドや定数を定義でき、match ($this) でケースごとの表示ラベルや判定を返す書き方が定番です。インターフェイスも実装できるため、複数の enum を共通の型で扱うこともできます。一方で、new でのインスタンス化やプロパティの宣言はできず、case の値は重複させられません。Pure Enum に ->value や from() は無く、json_encode() もできない点も覚えておいてください。まずは 'draft' のような文字列を持ち回っている箇所を1つ Backed Enum に置き換えて、引数の型宣言が効く安心感を試してみるのがおすすめです。

参考ページ