Laravel

Laravelのmigrationでenumを扱う実践ガイド|カラム定義・値の追加・Eloquent連携【Laravel 13対応】

Laravelでenumを扱うとき、実装者がつまずくのはたいてい「マイグレーションでどう書くか」ではなく「あとから選択肢を1つ増やしたいときにどうするか」です。日本語の解説記事の多くは「Laravelはenumカラムの変更に対応しておらず、Doctrine DBALも非対応だから生SQLを書くしかない」と説明していますが、この前提はLaravel 11でDoctrine DBALへの依存が削除された時点で崩れています。

この記事では、マイグレーションでのenumカラム定義から値の追加、PHP 8.1のenum(列挙型)とEloquentの連携、Rule::enumによる検証までを、Laravel 13.22.0時点のフレームワーク実装と公式ドキュメントに沿って整理します。

まとめ

マイグレーションでのenumカラムは$table->enum('status', ['pending', 'paid'])で定義します。ただしこの記法がネイティブのENUM型になるのはMySQL系(MySQL / MariaDB)だけで、PostgreSQL・SQLite・SQL Serverでは文字列カラム+CHECK制約に変換されます。Laravel 12以降はPHP enumのcases()をそのまま渡せるようになり、定義の二重管理が不要になりました。

値を後から追加する手順はLaravelのバージョンで正解が変わります。Laravel 11以降はDoctrine DBALが不要になり、->change()だけでMySQLのMODIFY文が発行されます。Laravel 10以前はDB::statement()で生SQLを書く従来の手順が必要です。ここを取り違えると、動くはずのコードを避けて不要な生SQLを書き続けることになります。

そして本記事の結論として、新規テーブルではデータベースのENUM型を選ばないことを推奨します。stringカラムにPHP enumのcastを組み合わせる構成なら、選択肢の追加がマイグレーション不要になり、型安全性も検証も失われません。以降でそれぞれの手順と、ENUM型を選んでよい例外条件を具体的に見ていきます。

migrationでのenumカラム定義と生成されるSQL

$table->enum()の基本形

スキーマビルダのenumメソッドは、カラム名と許可する値の配列を受け取ります。注文ステータスを持つテーブルなら次のように書きます。

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('orders', function (Blueprint $table) {
            $table->id();
            $table->enum('status', ['pending', 'paid', 'shipped'])->default('pending');
            $table->timestamps();
        });
    }
};

以降のコード例も、このuse宣言と匿名マイグレーションクラスの中に置く前提で読んでください。MySQLでこの行が組み立てるカラム定義はenum('pending', 'paid', 'shipped') not null default 'pending'です。既存テーブルへ後から追加した場合はalter table `orders` add `status` enum('pending', 'paid', 'shipped') not null default 'pending'が発行されます。フレームワークのテストDatabaseMySqlSchemaGrammarTest::testAddingEnumでも、同じ形のalter table `users` add `role` enum('member', 'admin') not nullという出力が検証されています。

default()nullable()のどちらも指定しなかった場合、既存レコードのあるテーブルへの追加はエラーにならずに通ってしまいます。MySQLのstrictモードが規定しているのはINSERT・REPLACE・UPDATEの挙動で、ALTER TABLE ADD COLUMNは対象外だからです。既存行にはENUMの暗黙デフォルトである先頭メンバー、この例ならpendingが黙って充填されます。意図しない初期値が全レコードに入る事故を避けるため、追加時はdefault()nullable()を必ず明示してください。

Laravel 12以降のcases()直接指定

アプリケーション側でPHPのbacked enumを定義しているなら、同じ値の配列をマイグレーションに書き写す必要はありません。

use App\Enums\OrderStatus;

Schema::create('orders', function (Blueprint $table) {
    $table->id();
    $table->enum('status', OrderStatus::cases())->default(OrderStatus::Pending->value);
    $table->timestamps();
});

これが動くのはLaravel 12以降です。Blueprint::enum()が受け取った配列をenum_value()ヘルパでマップする実装は12.xで入ったもので、10.xと11.xのBlueprint::enum()は配列をそのままaddColumnへ渡すだけでした。11以前でcases()を渡すと、enumインスタンスを文字列化できずObject of class OrderStatus could not be converted to stringで落ちます。

enum_value()の変換規則は把握しておく価値があります。BackedEnumなら->valueUnitEnum(値を持たない純粋なenum)なら->nameが使われます。この変換が効くのはDDLに書き出される許可値のリストです。

つまりenum OrderStatus: stringと宣言していればケースの値が、enum OrderStatusとだけ宣言していればケース名がDDLに並びます。さらに後述のEloquentのenumキャストはbacked enumを前提としているため、純粋なenumではキャストも掛けられません。ENUM型カラムと組み合わせるなら必ずbacked enumにしてください。使っているLaravelのバージョンとPHPの要件はLaravel 12の対応PHPバージョンと動作要件|サポート期限・11からのアップグレード・変更点で確認できます。

データベース別の生成カラム定義

enum()という同じ記述でも、接続先のデータベースによって出来上がるカラムはまったく別物です。

データベース 生成されるカラム定義 値の変更手段
MySQL / MariaDB enum(‘pending’, ‘paid’) MODIFY文で定義を張り替え
PostgreSQL varchar(255) + CHECK制約 CHECK制約の張り替え
SQLite varchar + CHECK制約 テーブル再構築
SQL Server nvarchar(255) + CHECK制約 CHECK制約の張り替え

PostgreSQLで生成されるDDLはalter table "orders" add column "status" varchar(255) check ("status" in ('pending', 'paid', 'shipped')) not nullです。PostgreSQLにはネイティブのCREATE TYPE ... AS ENUMがありますが、Laravelのスキーマビルダはそれを使いません。

この差が効いてくるのは、本番がMySQLでテストがSQLiteという構成のときです。SQLiteではSQLiteGrammar::compileChange()が空実装で、実際の変更はcompileAlterが一時テーブルの作成・データコピー・旧テーブルの削除・リネームへ展開します。->change()自体は通りますが、発行されるSQLの形も所要時間もMySQLのMODIFYとは別物です。SQLiteで通ったマイグレーションがMySQL本番の検証になっていない、という前提で扱ってください。MySQL固有の挙動についてはMySQLとは?特徴とバージョン選定・採用判断を実装目線で解説【2026年版】も参考になります。

enumカラムに値を後から追加する手順

Laravel 11以降のchange()による値追加

「canceled」を追加したい、という要件はenumカラムを使う限り必ず来ます。Laravel 11以降なら、追加後の完全な状態を書いてchange()を呼ぶだけです。

Schema::table('orders', function (Blueprint $table) {
    $table->enum('status', ['pending', 'paid', 'shipped', 'canceled'])
        ->default('pending')
        ->change();
});

MySQLではこれがalter table `orders` modify `status` enum('pending', 'paid', 'shipped', 'canceled') not null default 'pending'にコンパイルされます。MySqlGrammar::compileChange()がカラム型を再構築してmodify句を組み立てる実装になっているためです。

この手順が使えるようになったのはLaravel 11の変更が理由です。Laravel 11のアップグレードガイドには「Doctrine DBAL Removal」の項があり、Connection::registerDoctrineType()をはじめとするDBAL関連のクラスとメソッドが削除され、各種カラム型の作成・変更にカスタムDoctrine型の登録が不要になったと明記されています。日本語圏でよく引用される「enumの変更はDBALが非対応だから生SQLを書くしかない」という説明は、Laravel 10以前を前提にした情報です。

change()で起きる修飾子の消失

change()で最も踏みやすい地雷がこれです。公式ドキュメントのModifying Columnsが明記しているとおり、カラムを変更するときは残したい修飾子をすべて明示的に書き直す必要があり、書かなかった属性は削除されます

Schema::table('orders', function (Blueprint $table) {
    $table->enum('status', ['pending', 'paid', 'shipped', 'canceled'])->change();
});

この書き方は選択肢を1つ足しただけのつもりでも、元のdefault('pending')を消し飛ばします。unsigned()comment()nullable()も同じ扱いです。さらにchange()はインデックスには手を触れないため、インデックスを操作したい場合は->unique(false)のように別途明示します。値を1つ足す変更であっても、既存のカラム定義をスキーマから読み直して全修飾子を書き写す、という手順を運用ルールにしてください。

大テーブルでのinstant()指定とロック回避

数百万行のテーブルでenum定義を書き換えるとき、テーブル再構築が走るかどうかは無視できない差になります。MySQL 8.4のオンラインDDLリファレンスは、ENUM・SETカラムの定義変更について「有効なメンバー値のリストの末尾に新しいメンバーを追加する場合、データ型の格納サイズが変わらない限り、INSTANTまたはIN PLACEで実行できる」と記述しています。この条件を満たせばテーブル再構築は発生せず、メタデータのみの変更で同時DMLも許可されます。

逆にリストの途中にメンバーを挿入すると既存メンバーの再採番が起きるため、テーブルコピーが必要になります。新しい値は必ず配列の末尾に足す、という運用ルールがそのまま無停止デプロイの条件になるわけです。

Laravelでは、この最適化をマイグレーション側から明示できます。

Schema::table('orders', function (Blueprint $table) {
    $table->enum('status', ['pending', 'paid', 'shipped', 'canceled'])
        ->default('pending')
        ->instant()
        ->change();
});

生成されるSQLの末尾に, algorithm=instantが付き、INSTANTで実行できない変更だった場合はMySQLがエラーを返します。「気づかないうちに数分のテーブルコピーが走っていた」という事故を、デプロイ前に検出できる構えになります。

ただしinstant()lock()が使えるのはv12.40.1(2025年11月25日リリース)以降で、Laravel 11.xには存在しません。cases()の直接指定が12.0から使えるのに対し、こちらはパッチバージョンまで見る必要があります。12系を使っていてもcomposer.lockのバージョンが12.40.0以前なら未実装です。なおMySqlGrammar::compileChange()instant修飾子を参照する実装は12.xと13.xの双方にあるため、カラム追加だけでなくchange()との併用でも有効になります。

Laravel 10以前の生SQLによる変更

10.x以前を保守しているなら、従来どおりDB::statement()を使います。

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
    public function up(): void
    {
        DB::statement("ALTER TABLE orders MODIFY status ENUM('pending', 'paid', 'shipped', 'canceled') NOT NULL DEFAULT 'pending'");
    }

    public function down(): void
    {
        DB::statement("ALTER TABLE orders MODIFY status ENUM('pending', 'paid', 'shipped') NOT NULL DEFAULT 'pending'");
    }
};

生SQLで書く以上、この記述はMySQL専用になります。down()も忘れずに書いてください。ロールバック時に削除対象の値を持つレコードが残っていると、MODIFYがデータ切り詰めエラーで失敗します。値を減らすマイグレーションの前には、対象値のレコードを別の値へ移す処理を必ず挟みます。

PHPのenumとEloquentモデルの連携

backed enumの定義とcasts()での対応づけ

enum(列挙型)は、取り得る値の集合を型として宣言する言語機能です。PHPでは8.1から言語仕様に入り、値を伴う宣言をbacked enum(enum Status: string)、値を持たない宣言を純粋なenumと呼びます。ステータスに表示名や判定ロジックを持たせるなら、enum自身にメソッドを置くのが素直です。

<?php

namespace App\Enums;

enum OrderStatus: string
{
    case Pending = 'pending';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Canceled = 'canceled';

    public function label(): string
    {
        return match ($this) {
            self::Pending => '未入金',
            self::Paid => '入金済み',
            self::Shipped => '発送済み',
            self::Canceled => 'キャンセル',
        };
    }

    public function canShip(): bool
    {
        return $this === self::Paid;
    }
}

match式で全ケースを網羅すれば、ケースを追加したときにUnhandledMatchErrorが実行時に投げられます。定数の連想配列で同じことをやると、キーの追加漏れはnullとして静かに通過します。この「漏れたら止まる」性質が、enumをクラス定数の代わりに使う実質的な理由です。なおPHPのクラス定数はpublic constで宣言するもので、public static finalはJavaの記法です。

モデル側はcasts()メソッドでカラムとenumクラスを対応づけます。

<?php

namespace App\Models;

use App\Enums\OrderStatus;
use Illuminate\Database\Eloquent\Model;

class Order extends Model
{
    protected function casts(): array
    {
        return [
            'status' => OrderStatus::class,
        ];
    }
}

これで$order->statusOrderStatusのインスタンスを返し、代入もenumのケースで行えます。データベースに書き込まれるのは->value側の文字列です。

if ($order->status->canShip()) {
    $order->status = OrderStatus::Shipped;
    $order->save();
}

1カラム複数値のAsEnumCollectionとjson型

1つのカラムにenumの配列を保存したいときは、AsEnumCollectionまたはAsEnumArrayObjectキャストを使います。

use App\Enums\OrderStatus;
use Illuminate\Database\Eloquent\Casts\AsEnumCollection;

protected function casts(): array
{
    return [
        'status_history' => AsEnumCollection::of(OrderStatus::class),
    ];
}

この場合のカラム型はENUMではなくjsonです。ENUM型は1カラム1値なので、複数値を持たせたい時点でENUM型は選択肢から外れます。MySQLのSET型も存在しますが、値の追加時に格納サイズが変わるとテーブルコピーが発生するうえ、検索条件の書き方も特殊になるため、JSONカラムか中間テーブルを選ぶほうが後々の手当てが軽くなります。

Rule::enumによるリクエスト検証

Rule::enum()の基本形とonly/except

フォームやAPIから来る値の検証には、Rule::enum()を使います。プリミティブな値を検証する場合はbacked enumを渡します。

use App\Enums\OrderStatus;
use Illuminate\Validation\Rule;

$request->validate([
    'status' => [Rule::enum(OrderStatus::class)],
]);

ここでin:pending,paid,shippedと書いてしまうと、enumにケースを追加したときにバリデーションルールの更新漏れが起きます。Rule::enum()はenumの定義を唯一の情報源にするため、追加漏れが構造的に発生しません。

すべてのケースを許可したくない場面ではonlyexceptで絞り込みます。注文作成のエンドポイントで「未入金」しか受け付けたくない、といったケースです。これらはLaravel 10.xのドキュメントにも記載があり、新しい機能ではありません。

Rule::enum(OrderStatus::class)->only([OrderStatus::Pending]);

Rule::enum(OrderStatus::class)->except([OrderStatus::Canceled]);

when()による権限別の許可範囲切り替え

管理者だけがキャンセル状態への遷移を許される、という要件はwhenで表現できます。

Rule::enum(OrderStatus::class)->when(
    $request->user()->isAdmin(),
    fn ($rule) => $rule->except([OrderStatus::Shipped]),
    fn ($rule) => $rule->only([OrderStatus::Pending, OrderStatus::Paid]),
);

第2引数が条件成立時、第3引数が不成立時に適用されるクロージャです。コントローラ側でifを書いてルール配列を組み替える必要がなくなり、フォームリクエストクラスにルールを閉じ込められます。検証をどのレイヤに置くかの整理はバリデーションとは?入力検証の目的・種類・実装パターンと設計を実装者視点で解説で扱っています。

注意点として、Rule::enum()はアプリケーション層の検証にすぎません。データベースのENUM型制約とは独立しているため、両方を使う構成では「PHP enumに追加してマイグレーションを忘れる」ズレが起こり得ます。この二重管理こそが、次に述べる判断につながります。

データベースのENUM型を選ぶべきでない理由

ここまでの手順を踏まえたうえで、新規テーブルの設計指針をはっきり書きます。ステータス列にデータベースのENUM型を使わず、stringカラム+PHP enumのcast+Rule::enumで構成してください。

Schema::create('orders', function (Blueprint $table) {
    $table->id();
    $table->string('status', 20)->default('pending');
    $table->timestamps();
});

最大の理由は、選択肢の追加が本番へのDDLを伴わなくなることです。ENUM型ではケースを1つ足すたびにマイグレーションが必要で、末尾追加という条件を外せばテーブルコピーが走ります。数百万行のテーブルで営業時間中にこれをやる判断は、できれば避けたいはずです。stringならPHP enumにケースを足してデプロイするだけで済みます。

次に、この構成で失われるものがありません。casts()でPHP enumにひもづけている限りモデル経由のアクセスは型安全なままですし、不正な値はRule::enum()が入口で弾きます。データベース制約が担っていた役割は、アプリケーション層でそのままカバーできます。

加えて、データベース間で挙動が揃うという副次的な利点もあります。前述のとおりENUM型がネイティブなのはMySQL系だけで、他の3系統ではCHECK制約に化けました。stringならどのデータベースでも同じ文字列カラムです。

それでもENUM型を選ぶ価値があるのは、次の条件が揃うときに限られます。値の集合が制度や規格で固定されていて増減しない(都道府県コード、血液型など)。データベースへ直接書き込む経路がアプリケーション以外にも存在し、データベース側の制約でしか不正値を止められない。この2つが両方成り立つ場合だけです。「ステータス」「区分」「種別」といった業務都合で増える列は、ほぼ確実にこの条件を外れます。

なおstringカラムを選ぶ場合はインデックスの効きを意識してください。ステータス列は値の種類が少ない低カーディナリティのカラムなので、単体インデックスは効かないことが多く、他の列との複合インデックスにするか、そもそも張らない判断が必要です。判断の目安はカーディナリティとは?意味・高低の見分け方とインデックス設計を実装目線で解説にまとめています。

状態そのものの設計、つまり「どの状態からどの状態へ遷移できるか」を型で縛りたい場合は、ステートマシンとは?状態遷移の設計と実装パターン・採用判断を実装者目線で解説の考え方をenumのメソッドに落とし込むと、遷移の妥当性チェックが一箇所に集まります。

よくある質問

migrationでenumカラムを定義する最短の書き方は?

$table->enum('status', ['pending', 'paid'])->default('pending');です。既存レコードのあるテーブルへ追加するときにdefault()nullable()のどちらも省略すると、エラーにはならず既存行すべてにENUMの先頭メンバーが暗黙のデフォルトとして入ります。意図しない初期値が黙って入る事故を避けるため、どちらかを明示してください。Laravel 12以降ならPHP enumのOrderStatus::cases()を配列の代わりに渡せます。

enumカラムに選択肢をあとから追加するにはどうすればいい?

Laravel 11以降は、追加後の全選択肢と残したい修飾子をすべて書いたうえで->change()を呼ぶだけです。Doctrine DBALのインストールは不要になりました。Laravel 10以前はDB::statement()ALTER TABLE ... MODIFYを直接書きます。どちらの場合も、新しい値はリストの末尾に足してください。

PHPのenumとデータベースのENUM型は同じもの?

別物です。PHPのenumはPHP 8.1で入った言語機能で、型としてケースを表現します。データベースのENUM型はMySQL系のカラム型で、格納できる文字列を定義で制限します。両者はcasts()でひもづけられますが、片方だけを更新すると不整合が起きるため、実務ではstringカラム+PHP enumの構成にして二重管理を避けるのが安全です。

バリデーションはRule::enumとin:のどちらを使うべき?

Rule::enum()です。in:pending,paidはenumにケースを追加したときの更新漏れが検出できませんが、Rule::enum()はenumの定義を直接参照します。許可範囲を絞りたい場合はonly()except()、条件で切り替えたい場合はwhen()を使います。

MySQLのENUM型はテーブルが大きいと変更に時間がかかる?

条件次第です。MySQL 8.4のリファレンスによれば、新しいメンバーをリストの末尾に追加し、かつ格納サイズが変わらない場合はメタデータのみの変更で済み、テーブル再構築は起きません。一方、リストの途中に挿入すると既存メンバーの再採番が発生してテーブルコピーになります。Laravel v12.40.1以降なら->instant()を付けて、INSTANTで実行できない変更を事前にエラーとして検出できます。

関連記事

資料請求

RELATED POSTS 関連記事