サイトアイコン 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 には draftpublishedarchived のような値を入れる想定です。画面上では「下書き」「公開中」「アーカイブ済み」と翻訳・表示できます。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_activetype より notification_typevalue より 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

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

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

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

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

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


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

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

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

これは user_idusers.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変更を含むデプロイでは、旧コードと新コードが一時的に混在する可能性があります。ゼロダウンタイムや複数台構成では特に重要です。

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

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

例として、namefirst_namelast_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を見直し、意味が伝わる名前・安全な変更・再実行に強い初期データへ少しずつ整えていきましょう。


参考リンク

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