サイトアイコン IT & ライフハックブログ|学びと実践のためのアイデア集

【実務完全ガイド】Laravelのマイグレーション・シーディング・Factory設計――安全なDB変更、テストデータ、初期データ、ロールバックまで

php elephant sticker

Photo by RealToughCandy.com on Pexels.com

【実務完全ガイド】Laravelのマイグレーション・シーディング・Factory設計――安全なDB変更、テストデータ、初期データ、ロールバックまで

この記事で学べること(要点)

  • Laravelのマイグレーションを安全に設計する考え方
  • カラム追加・変更・削除で事故を減らす実務パターン
  • SeederとFactoryを使った初期データ・テストデータの作り方
  • 本番デプロイ時に気をつけたいDB変更の順序
  • ロールバック、バックアップ、データ移行、ゼロダウンタイムを意識した運用
  • 管理画面やフォームとつながる、分かりやすいデータ設計
  • アクセシビリティを考慮した初期データ・文言・状態管理のポイント

想定読者

  • Laravel 初〜中級エンジニア:マイグレーションは書けるが、本番DB変更が不安な方
  • テックリード:チーム内でDB変更のルールやレビュー基準を整えたい方
  • QA / 保守担当:テストデータや初期データが不安定で、検証しづらいと感じている方
  • デザイナー / CS / アクセシビリティ担当:ステータス名、初期文言、画面表示に使うデータを分かりやすく整えたい方

アクセシビリティレベル:★★★★☆
この記事の主題はDB設計ですが、データの持ち方はUIの分かりやすさに直結します。たとえばステータス値、ロール名、通知テンプレート、フォーム選択肢が整理されていないと、画面側で説明しづらくなります。本記事では、色だけに頼らない状態表示、読みやすいラベル、翻訳しやすい初期データなども意識して解説します。


1. はじめに:マイグレーションは「DBの履歴書」です

Laravelのマイグレーションは、データベース構造をコードとして管理する仕組みです。テーブルを作る、カラムを追加する、インデックスを張る、外部キーを定義する、といった変更をファイルとして残せます。これは単なる便利機能ではなく、チーム開発や本番運用で非常に重要な役割を持ちます。

DB変更を手作業で行うと、「誰が、いつ、何を変えたか」が曖昧になります。ローカルでは動くのにステージングではカラムが無い、本番だけインデックスが足りない、テスト環境の初期データが違う、といった問題が起きやすくなります。マイグレーションを使うことで、DBの変更履歴をGitで管理でき、同じ手順をチーム全員が再現できるようになります。

ただし、マイグレーションは便利な反面、本番での扱いには注意が必要です。特にカラム削除、型変更、大量データ更新、外部キー追加、巨大テーブルへのインデックス追加は、サービス停止や長時間ロックの原因になることがあります。この記事では、Laravelのマイグレーションを「書き方」だけでなく、「安全に運用する考え方」として整理していきます。


2. 基本のマイグレーション:テーブル作成から始めます

まずは基本のテーブル作成です。たとえば記事テーブルを作る場合は、次のようにコマンドを実行します。

php artisan make:migration create_posts_table

生成されたファイルに、テーブル構造を書きます。

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('posts', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('title', 120);
            $table->string('slug')->unique();
            $table->text('body');
            $table->string('status', 30)->default('draft');
            $table->timestamp('published_at')->nullable();
            $table->timestamps();

            $table->index(['status', 'published_at']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('posts');
    }
};

この例で大切なのは、カラム名と状態が画面で使いやすい形になっていることです。
status には draft、published、archived のような値を入れる想定です。画面上では「下書き」「公開中」「アーカイブ済み」と翻訳・表示できます。DBには機械的に扱いやすい値を入れ、UIでは人が読みやすいラベルに変換する設計が扱いやすいです。


3. 命名ルール:後から読んでも分かる名前にします

マイグレーションファイル名は、後から履歴として読むことになります。
そのため、何をしたファイルか分かる名前にするのがおすすめです。

良い例:

create_posts_table
add_status_to_orders_table
add_published_at_to_posts_table
create_notification_preferences_table

分かりにくい例:

update_posts
fix_table
change_columns
add_fields

チーム開発では、マイグレーション名を見るだけで変更内容の概要が分かることが大切です。レビューでも、ファイル名が具体的だと内容を確認しやすくなります。

また、テーブル名やカラム名もUIや業務とつながるため、曖昧な名前は避けます。
たとえば flag より is_active、type より notification_type、value より display_order のように、意味が伝わる名前を選ぶと保守しやすくなります。


4. カラム追加:まずはnullableやdefaultを慎重に考えます

既存テーブルにカラムを追加する場合、本番データがすでに存在することを前提に考える必要があります。
たとえば users テーブルに timezone を追加する例です。

php artisan make:migration add_timezone_to_users_table
return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('timezone', 64)->nullable()->after('email');
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn('timezone');
        });
    }
};

最初は nullable() にしておくと、既存データがあっても安全に追加しやすいです。その後、アプリ側で未設定時のフォールバックを入れます。

$timezone = $user->timezone ?? config('app.timezone');

全ユーザーに値が入ったことを確認してから、必要であれば nullable を外す変更を検討します。
いきなり not null で追加すると、既存データに値が無くて失敗することがあります。DB変更は「追加」「移行」「制約強化」の順に分けると安全です。


5. カラム削除:本番では特に慎重に進めます

カラム削除は、DB変更の中でも危険度が高い操作です。理由は、一度削除するとアプリの旧コードが参照した瞬間にエラーになるからです。ゼロダウンタイムを目指す場合、いきなり削除せず段階を踏みます。

安全な流れの例:

  1. 新しいカラムを追加する
  2. アプリを新カラム対応にする
  3. 旧カラムを使わなくなる
  4. ログや監視で旧カラム参照がないことを確認する
  5. 別リリースで旧カラムを削除する

つまり、カラム削除は「最後の片付け」です。
削除マイグレーションは、機能変更と同じリリースに入れない方が安全なことが多いです。

Schema::table('posts', function (Blueprint $table) {
    $table->dropColumn('old_summary');
});

このような操作は、レビュー時に「本当にアプリから参照されていないか」「バックアップはあるか」「ロールバック時に困らないか」を確認します。


6. インデックス設計:検索と一覧の速度を支えます

DBが遅くなる原因の多くは、インデックス不足や不適切なインデックスです。Laravelのマイグレーションでは、インデックスもコードで管理できます。

$table->index('status');
$table->index(['status', 'published_at']);
$table->unique('slug');

一覧画面でよく使う条件を考えて設計します。
たとえば、公開済み記事を公開日時順に表示するなら、次のような条件がよく出ます。

WHERE status = 'published'
ORDER BY published_at DESC

この場合、status と published_at の複合インデックスを検討できます。

$table->index(['status', 'published_at']);

ただし、インデックスは多ければよいわけではありません。書き込み時の負荷やストレージ使用量も増えます。
実務では、次の順番で考えると安全です。

  • よく使う検索条件は何か
  • よく使う並び順は何か
  • 一覧画面の件数は多いか
  • DBの実行計画でインデックスが使われているか
  • 不要なインデックスが増えすぎていないか

インデックスは、画面の体感速度と運用コストの両方に影響します。


7. 外部キー制約:データ整合性をDBでも守ります

Laravelでは、外部キー制約も簡潔に書けます。

$table->foreignId('user_id')
    ->constrained()
    ->cascadeOnDelete();

これは user_id が users.id を参照し、ユーザー削除時に関連データも削除する設定です。
ただし、cascadeOnDelete() は便利ですが、使いどころに注意が必要です。ユーザー削除と同時に投稿や注文が消える設計で本当に良いのか、業務要件に合わせて考えます。

たとえば注文データは、ユーザーを削除しても履歴として残したい場合があります。その場合は、外部キーの削除動作を変えるか、ユーザーを物理削除せずステータスで停止する設計にします。

$table->foreignId('user_id')
    ->nullable()
    ->constrained()
    ->nullOnDelete();

DB制約は、アプリ側のバリデーションとは別の最後の防衛線です。
アプリのバグや想定外操作があっても、DBが整合性を守ってくれる場面があります。


8. ステータス値の設計:UIで分かりやすく表示できるようにします

多くのアプリでは、status カラムを使います。
たとえば注文なら、次のような状態があります。

  • pending
  • paid
  • shipped
  • cancelled

DBには英語の機械的な値を入れ、画面では翻訳して表示します。

enum OrderStatus: string
{
    case Pending = 'pending';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Cancelled = 'cancelled';

    public function label(): string
    {
        return match ($this) {
            self::Pending => '支払い待ち',
            self::Paid => '支払い済み',
            self::Shipped => '発送済み',
            self::Cancelled => 'キャンセル済み',
        };
    }
}

LaravelのキャストでEnumを使えます。

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

状態表示では、色だけで意味を伝えないようにします。
たとえば赤いバッジだけでなく、「キャンセル済み」という文字を必ず表示します。これはアクセシビリティだけでなく、運用UIの誤解防止にも有効です。


9. Seeder:初期データをコードで再現します

Seederは、初期データを投入する仕組みです。
ロール、権限、カテゴリ、設定値、開発用データなどに使えます。

php artisan make:seeder RoleSeeder
namespace Database\Seeders;

use Illuminate\Database\Seeder;
use App\Models\Role;

class RoleSeeder extends Seeder
{
    public function run(): void
    {
        foreach (['admin', 'editor', 'viewer'] as $role) {
            Role::firstOrCreate(['name' => $role]);
        }
    }
}

DatabaseSeeder から呼びます。

public function run(): void
{
    $this->call([
        RoleSeeder::class,
    ]);
}

Seederで大切なのは、何度実行しても壊れないことです。
create() だけを使うと、再実行時に重複することがあります。初期データには firstOrCreate() や updateOrCreate() を使うと安全です。


10. 本番Seederと開発Seederを分けます

開発用のダミーデータと、本番に必要な初期データは分けて考えます。

本番に必要な初期データの例:

  • ロール
  • 権限
  • 都道府県
  • 通知テンプレート
  • システム設定
  • 固定カテゴリ

開発用データの例:

  • テストユーザー
  • ダミー記事
  • サンプル注文
  • 大量のFactoryデータ

本番で開発用Seederを実行すると危険です。
Seeder名や呼び出し方を分けて、環境ごとに実行対象を明確にします。

public function run(): void
{
    $this->call([
        RoleSeeder::class,
        PermissionSeeder::class,
    ]);

    if ($this->app->environment('local')) {
        $this->call([
            DemoUserSeeder::class,
            DemoPostSeeder::class,
        ]);
    }
}

11. Factory:テストデータを読みやすく作ります

Factoryは、テストや開発でデータを作るための仕組みです。

php artisan make:factory PostFactory --model=Post
namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;

class PostFactory extends Factory
{
    public function definition(): array
    {
        return [
            'user_id' => User::factory(),
            'title' => fake()->sentence(4),
            'slug' => fake()->unique()->slug(),
            'body' => fake()->paragraphs(3, true),
            'status' => 'draft',
            'published_at' => null,
        ];
    }

    public function published(): static
    {
        return $this->state(fn () => [
            'status' => 'published',
            'published_at' => now()->subDay(),
        ]);
    }
}

使い方:

Post::factory()->published()->create();

Factoryに状態メソッドを用意すると、テストが読みやすくなります。

public function test_published_posts_are_visible()
{
    Post::factory()->published()->create(['title' => '公開記事']);
    Post::factory()->create(['title' => '下書き記事']);

    $response = $this->get('/posts');

    $response->assertSee('公開記事')
        ->assertDontSee('下書き記事');
}

テストデータは、単に数を増やすだけではなく、「意図が分かる状態」を作ることが大切です。


12. Factoryとリレーション:現実に近いデータを作ります

リレーションを含むテストデータも、Factoryで作れます。

$user = User::factory()
    ->has(Post::factory()->count(3)->published())
    ->create();

タグ付き投稿のような多対多関係も作れます。

$post = Post::factory()
    ->published()
    ->hasAttached(Tag::factory()->count(2))
    ->create();

ただし、テストで必要以上に大きなデータを作ると、テストが遅くなります。
重要なのは、現実に近い構造を小さく作ることです。
「100件必要」ではなく「この条件を満たす2件が必要」と考えると、テストは速く読みやすくなります。


13. データ移行:マイグレーション内で大量更新しすぎない

マイグレーションでカラムを追加すると同時に、既存データを更新したくなることがあります。
小さなテーブルなら問題ありませんが、大量データをマイグレーション内で一気に更新すると、本番デプロイが長時間止まる可能性があります。

危険な例:

public function up(): void
{
    Schema::table('users', function (Blueprint $table) {
        $table->string('timezone')->nullable();
    });

    User::query()->update(['timezone' => 'Asia/Tokyo']);
}

大量データの場合は、次のように分ける方が安全です。

  1. カラムを追加する
  2. アプリ側で未設定時のフォールバックを入れる
  3. バッチやジョブで少しずつデータを埋める
  4. 全件埋まったことを確認する
  5. 必要なら nullable を外す

DB構造変更と大量データ更新は、同じマイグレーションに詰め込みすぎない方が安全です。


14. ロールバック:戻せる変更と戻せない変更を分けます

Laravelのマイグレーションには down() があります。ただし、本番では単純にロールバックできない変更もあります。

戻しやすい変更:

  • 新規テーブル作成
  • nullableカラム追加
  • インデックス追加

戻しにくい変更:

  • カラム削除
  • 型変更
  • データ変換
  • 既存値の上書き

特にデータを失う変更は、down() を書いていても元に戻せないことがあります。
たとえばカラムを削除したあと、ロールバックでカラムを再作成しても、元のデータは戻りません。

そのため、ロールバックに頼りすぎず、次を準備します。

  • デプロイ前バックアップ
  • ステージングでの事前実行
  • 小さな変更単位
  • 互換性のあるリリース手順
  • 必要なら手動復旧手順

down() は大切ですが、「本当に戻せるか」は別問題として考える必要があります。


15. 本番デプロイ時のDB変更:互換性を意識します

DB変更を含むデプロイでは、旧コードと新コードが一時的に混在する可能性があります。ゼロダウンタイムや複数台構成では特に重要です。

安全な原則は次の通りです。

  • まず追加する
  • 新旧どちらのコードでも動く状態にする
  • 新コードへ切り替える
  • 古い構造を後日削除する

例として、name を first_name と last_name に分ける場合、いきなり name を削除するのは危険です。
まず新カラムを追加し、アプリ側で両方扱えるようにし、データ移行し、完全に移行してから旧カラムを削除します。

このように、DB変更はコード変更よりも慎重に扱います。
テーブル構造はアプリ全体の土台なので、急な削除や変更は避けるのが安全です。


16. アクセシビリティとDB設計:状態・ラベル・選択肢を整える

DB設計は、画面の分かりやすさにも影響します。
たとえば、ステータス値が曖昧だと、UIでも曖昧な表示になります。

避けたい例:

status = 1
status = 2
status = 3

このような数値だけの状態は、あとから読みにくくなります。
もし使うなら、Enumや定数で意味を明確にします。

望ましい例:

draft
published
archived

画面では次のように表示できます。

  • 下書き
  • 公開中
  • アーカイブ済み

色を付ける場合でも、文字ラベルがあるため、色覚に依存しません。
また、選択肢や初期データは翻訳しやすい構造にしておくと、多言語化にも対応しやすくなります。


17. テスト:マイグレーション、Seeder、Factoryの品質を守ります

Laravelでは、テスト時にマイグレーションを使ってDBを構築できます。
RefreshDatabase を使うと、テストごとにDB状態を整えやすくなります。

use Illuminate\Foundation\Testing\RefreshDatabase;

class PostTest extends TestCase
{
    use RefreshDatabase;

    public function test_published_post_is_visible(): void
    {
        Post::factory()->published()->create([
            'title' => '公開記事',
        ]);

        $response = $this->get('/posts');

        $response->assertSee('公開記事');
    }
}

Seederのテストも重要です。
特にロールや権限のような初期データは、入っていないとアプリが動かないことがあります。

public function test_roles_are_seeded(): void
{
    $this->seed(RoleSeeder::class);

    $this->assertDatabaseHas('roles', [
        'name' => 'admin',
    ]);
}

Factoryは、テストの読みやすさに直結します。
状態メソッドを増やしすぎる必要はありませんが、よく使う状態はFactoryへまとめておくと便利です。


18. よくある落とし穴と回避策

18.1 本番でいきなりnot nullカラムを追加する

既存データがあると失敗する可能性があります。
まず nullable で追加し、データ移行後に制約を強化します。

18.2 カラム削除を機能変更と同じリリースで行う

旧コードが参照するとエラーになります。
削除は後日、参照が無いことを確認してから行います。

18.3 Seederが重複データを作る

create() だけだと再実行で重複します。
firstOrCreate() や updateOrCreate() を使います。

18.4 Factoryが現実離れしすぎる

テストでしか成立しないデータになると、バグを見逃します。
最低限の整合性を持つFactoryにします。

18.5 ステータス値が数値だけで意味不明

画面表示や保守が難しくなります。
Enumや文字列値で意味を明確にします。

18.6 マイグレーションに大量データ更新を詰め込む

デプロイが長時間止まる可能性があります。
バッチやジョブで段階的に移行します。


19. チェックリスト(配布用)

マイグレーション設計

  • [ ] ファイル名が変更内容を表している
  • [ ] カラム名が意味を持っている
  • [ ] 既存データがある前提で nullable / default を考えている
  • [ ] カラム削除を慎重に分けている
  • [ ] インデックスが検索条件に合っている
  • [ ] 外部キー制約の削除動作を業務要件に合わせている

本番運用

  • [ ] DB変更前にバックアップを確認している
  • [ ] 大量データ更新をマイグレーションに詰め込んでいない
  • [ ] 追加→移行→削除の順序を守っている
  • [ ] ロールバックで本当に戻せるか確認している

Seeder

  • [ ] 本番初期データと開発用データを分けている
  • [ ] firstOrCreate() / updateOrCreate() で再実行に強い
  • [ ] ロールや権限など必須データのSeederがある

Factory

  • [ ] よく使う状態メソッドがある
  • [ ] リレーション付きデータを自然に作れる
  • [ ] テストに必要な最小件数で作っている

アクセシビリティ / UI連携

  • [ ] ステータス値が画面で分かりやすく表示できる
  • [ ] 色だけに依存しない状態ラベルを作れる
  • [ ] 選択肢や初期データが翻訳しやすい
  • [ ] 管理画面で意味のあるラベルに変換できる

20. まとめ

Laravelのマイグレーションは、DB構造を管理するだけでなく、チーム開発と本番運用を安定させるための重要な仕組みです。テーブル作成、カラム追加、インデックス、外部キーをコードで管理することで、環境差を減らし、変更履歴を明確にできます。

一方で、本番DB変更は慎重に扱う必要があります。特に、not null追加、カラム削除、型変更、大量データ移行は、追加→移行→削除のように段階を分けると安全です。SeederとFactoryを整えることで、初期データやテストデータも再現しやすくなります。

DB設計はバックエンドだけの話ではありません。ステータス値、ロール名、選択肢、初期文言は、画面の分かりやすさやアクセシビリティにもつながります。データを機械にも人にも扱いやすく整えることで、Laravelアプリ全体の品質が上がります。まずは、既存のマイグレーションとFactoryを見直し、意味が伝わる名前・安全な変更・再実行に強い初期データへ少しずつ整えていきましょう。


参考リンク

モバイルバージョンを終了