---
title: "Laravelのmigrationでenumを扱う実践ガイド｜カラム定義・値の追加・Eloquent連携【Laravel 13対応】"
url: "https://www.issoh.co.jp/tech/details/4422/"
published: 2024-12-06
updated: 2026-10-30
categories: ["Laravel"]
publisher: "株式会社一創"
---

# 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`なら`->value`、`UnitEnum`（値を持たない純粋なenum）なら`->name`が使われます。この変換が効くのはDDLに書き出される許可値のリストです。

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

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

`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年版】](/tech/details/13135/)も参考になります。

## 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のアップグレードガイド](https://laravel.com/docs/11.x/upgrade)には「Doctrine DBAL Removal」の項があり、`Connection::registerDoctrineType()`をはじめとするDBAL関連のクラスとメソッドが削除され、各種カラム型の作成・変更にカスタムDoctrine型の登録が不要になったと明記されています。日本語圏でよく引用される「enumの変更はDBALが非対応だから生SQLを書くしかない」という説明は、Laravel 10以前を前提にした情報です。

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

`change()`で最も踏みやすい地雷がこれです。[公式ドキュメントのModifying Columns](https://laravel.com/docs/13.x/migrations#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リファレンス](https://dev.mysql.com/doc/refman/8.4/en/innodb-online-ddl-operations.html)は、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->status`は`OrderStatus`のインスタンスを返し、代入も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の定義を唯一の情報源にするため、追加漏れが構造的に発生しません。

すべてのケースを許可したくない場面では`only`と`except`で絞り込みます。注文作成のエンドポイントで「未入金」しか受け付けたくない、といったケースです。これらは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`を書いてルール配列を組み替える必要がなくなり、フォームリクエストクラスにルールを閉じ込められます。検証をどのレイヤに置くかの整理は[バリデーションとは？入力検証の目的・種類・実装パターンと設計を実装者視点で解説](/tech/details/13486/)で扱っています。

注意点として、`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`カラムを選ぶ場合はインデックスの効きを意識してください。ステータス列は値の種類が少ない低カーディナリティのカラムなので、単体インデックスは効かないことが多く、他の列との複合インデックスにするか、そもそも張らない判断が必要です。判断の目安は[カーディナリティとは？意味・高低の見分け方とインデックス設計を実装目線で解説](/tech/details/14785/)にまとめています。

状態そのものの設計、つまり「どの状態からどの状態へ遷移できるか」を型で縛りたい場合は、[ステートマシンとは？状態遷移の設計と実装パターン・採用判断を実装者目線で解説](/tech/details/14686/)の考え方を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で実行できない変更を事前にエラーとして検出できます。

## 関連記事

- [Laravel 12の対応PHPバージョンと動作要件｜サポート期限・11からのアップグレード・変更点](/tech/details/17116/)
- [バリデーションとは？入力検証の目的・種類・実装パターンと設計を実装者視点で解説](/tech/details/13486/)
- [MySQLとは？特徴とバージョン選定・採用判断を実装目線で解説【2026年版】](/tech/details/13135/)
- [ステートマシンとは？状態遷移の設計と実装パターン・採用判断を実装者目線で解説](/tech/details/14686/)
- [カーディナリティとは？意味・高低の見分け方とインデックス設計を実装目線で解説](/tech/details/14785/)

---

出典: [Laravelのmigrationでenumを扱う実践ガイド｜カラム定義・値の追加・Eloquent連携【Laravel 13対応】](<https://www.issoh.co.jp/tech/details/4422/>)（株式会社一創）
