404 Not Found

404 Not Found


nginx

Laravelデータベースとマイグレーションシステム

マイグレーションはLaravelの「データベースバージョン管理」です—Gitがコードを管理するようにデータベース構造を管理するため, チームはテーブル構造の同期ズレに悩まされることがなくなります。

1. 学ぶ内容


2. DBAの実話

(1) 悩み:手動SQL実行が本番インシデントを引き起こす

Charlieは本番環境でALTER TABLE orders ADD COLUMN discount DECIMAL(8,2)を手動実行しましたが, デフォルト値の指定を忘れました—結果として, 200万件のレコードの「discount」列がすべてNULLになり, レポートモジュールが2時間クラッシュしました。さらに悪いことに, Bobはローカルでフィールドを追加したことをCharlieに伝えておらず, コードがデプロイされた時点で即座にSQLエラーが発生しました。

(2) Laravelマイグレーションによる解決策

LaravelマイグレーションはPHPコードでデータベース変更を記述します。チームはマイグレーションファイルを共有し, 実行順序は自動的に追跡されます—全員がphp artisan migrateを実行すれば, データベース構造は完全に一致します。

BASH
php artisan make:migration add_discount_to_orders_table
# タイムスタンプ付きマイグレーションファイルを作成
php artisan migrate
# 未実行のマイグレーションを順番に実行

(3) 成果

Charlieが手動SQLをマイグレーションに置き換えた後, 新しいフィールドにはデフォルト値の指定が必須となり (コードレビューで検出可能), Bobがローカル変更をGitにプッシュすればCharlieは1コマンドで同期でき, インシデントはゼロになりました。


3. マイグレーションの基礎

(1) マイグレーションファイルの作成

BASH
# マイグレーションを作成
php artisan make:migration create_shops_table

# テーブル名のヒント付き
php artisan make:migration create_shops_table --create=shops

# 既存テーブルにカラムを追加
php artisan make:migration add_status_to_shops_table --table=shops

(2) マイグレーションファイルの構造

PHP
// database/migrations/2024_01_15_000000_create_shops_table.php
return new class extends Migration
{
    public function up(): void
    {
        Schema::create('shops', function (Blueprint $table) {
            $table->id();
            $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
            $table->string('name');
            $table->string('slug')->unique();
            $table->text('description')->nullable();
            $table->enum('status', ['active', 'suspended', 'closed'])->default('active');
            $table->decimal('revenue', 12, 2)->default(0);
            $table->timestamps();
            $table->softDeletes();
        });
    }

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

(3) マイグレーションコマンド

コマンド 機能
migrate 未実行のマイグレーションを実行
migrate:rollback 前のマイグレーションをロールバック
migrate:refresh 全ロールバック + 再実行
migrate:fresh データベース削除 + 再実行
migrate:status マイグレーション状態の確認
migrate:reset 全マイグレーションをロールバック

(1) ▶ サンプル:マイグレーションの作成と実行

BASH
# マイグレーションを作成
php artisan make:migration create_shops_table

# 未実行のマイグレーションを実行
php artisan migrate
# 2024_01_15_000000_create_shops_table .............. done

# マイグレーション状態の確認
php artisan migrate:status
# Ran?   Migration
# Yes    0001_01_01_000000_create_users_table
# Yes    2024_01_15_000000_create_shops_table
# No     2024_01_16_000000_create_products_table

出力:

TEXT
# コマンド実行成功

4. Schema Builder

(1) 一般的なカラム型

メソッド データベース型 説明
id() BIGINT UNSIGNED AUTO_INCREMENT 主キー
foreignId('x') BIGINT UNSIGNED 外部キー
string('name', 255) VARCHAR 文字列
text('content') TEXT 長文テキスト
integer('count') INT 整数
decimal('price', 8, 2) DECIMAL(8,2) 高精度小数
boolean('active') TINYINT(1) 真偽値
enum('status', [...]) ENUM 列挙型
json('metadata') JSON JSONデータ
timestamp('published_at') TIMESTAMP タイムスタンプ
softDeletes() TIMESTAMP NULL ソフトデリート

(2) インデックスと制約

PHP
Schema::create('orders', function (Blueprint $table) {
    $table->id();
    $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
    $table->foreignId('shop_id')->constrained()->cascadeOnDelete();
    $table->string('order_number')->unique();
    $table->decimal('total', 12, 2);

    // インデックス
    $table->index('shop_id');          // 単一インデックス
    $table->index(['shop_id', 'status']); // 複合インデックス
    $table->unique(['tenant_id', 'order_number']); // ユニーク複合

    // 外部キー制約
    $table->foreignId('user_id')
        ->constrained('users')         // カスタムテーブル名
        ->cascadeOnDelete()            // 親レコード削除時に関連も削除
        ->cascadeOnUpdate();           // 親レコード更新時に関連も更新

    $table->timestamps();
});
制約メソッド 機能
unique() ユニークインデックス
index() 通常インデックス
constrained() 外部キー関係を自動推論
cascadeOnDelete() カスケード削除
restrictOnDelete() 削除制限 (子レコードがあるとエラー)
nullOnDelete() 親レコード削除時にNULLに設定

(1) ▶ サンプル:ShopMetricsマルチテナント外部キー設計

PHP
// database/migrations/create_tenants_table.php
Schema::create('tenants', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('slug')->unique();
    $table->string('domain')->unique();
    $table->enum('status', ['active', 'suspended', 'cancelled'])->default('active');
    $table->timestamps();
    $table->softDeletes();
});

// database/migrations/create_subscriptions_table.php
Schema::create('subscriptions', function (Blueprint $table) {
    $table->id();
    $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
    $table->foreignId('plan_id')->constrained()->cascadeOnDelete();
    $table->enum('status', ['active', 'past_due', 'cancelled'])->default('active');
    $table->timestamp('trial_ends_at')->nullable();
    $table->timestamp('ends_at')->nullable();
    $table->timestamps();

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

出力:

TEXT
// 実行成功

5. マルチテナントテーブル設計

(1) ShopMetricsコアテーブル

100%
timeline
    title ShopMetricsマイグレーションタイムライン
    Create tenants : tenantsテーブル
    Create plans : plansテーブル
    Create users : tenant_id付きusersテーブル
    Create subscriptions : subscriptionsテーブル
    Create shops : tenant_id付きshopsテーブル
    Create products : shop_id付きproductsテーブル
    Create categories : categoriesテーブル
    Create category_product : ピボットテーブル
    Create orders : tenant_id + shop_id付きordersテーブル
    Create order_items : order_id + product_id付きorder_itemsテーブル

(2) 完全なマイグレーション順序

順序 テーブル名 主要フィールド
1 tenants id, name, slug, domain, status
2 plans id, name, price, features (JSON)
3 users id, tenant_id (FK), name, email, role
4 subscriptions id, tenant_id (FK), plan_id (FK), status
5 shops id, tenant_id (FK), name, slug, revenue
6 products id, shop_id (FK), name, price, sku
7 categories id, name, slug
8 category_product category_id (FK), product_id (FK)
9 orders id, tenant_id (FK), shop_id (FK), total, status
10 order_items id, order_id (FK), product_id (FK), qty, price

(1) ▶ サンプル:ShopMetrics完全ユーザーテーブルマイグレーション

PHP
// database/migrations/create_users_table.php
Schema::create('users', function (Blueprint $table) {
    $table->id();
    $table->foreignId('tenant_id')->nullable()->constrained()->nullOnDelete();
    $table->string('name');
    $table->string('email')->unique();
    $table->timestamp('email_verified_at')->nullable();
    $table->string('password');
    $table->enum('role', ['super_admin', 'tenant_owner', 'analyst', 'viewer'])
        ->default('viewer');
    $table->rememberToken();
    $table->timestamps();
    $table->softDeletes();

    $table->index(['tenant_id', 'role']);
    $table->index('email');
});

出力:

TEXT
// 実行成功

6. 本番マイグレーション戦略

(1) 安全なマイグレーションの原則

原則 説明 違反時の結果
新規追加にはデフォルト値必須 ->default(0)または->nullable() 既存レコードでエラー
カラム削除は先にコードを削除 まずカラムの使用を停止し, 次バージョンで削除 コードが存在しないカラムを参照
after()でカラム位置を指定 テーブル再構築を減らす テーブルロック時間が長すぎる
マイグレーションをトランザクション内で withinTransactionプロパティ 部分的成功, 部分的失敗

(2) MySQLとPostgreSQLの違い

機能 MySQL PostgreSQL
カラムデフォルト値 即座に追加 テーブルの上書きが必要
JSONカラム json() json() + jsonb()
列挙型 enum() string + CHECKを推奨
フルテキストインデックス fullText() fullText() + GIN
外部キーチェック 一時的に無効化可能 厳密チェック

(1) ▶ サンプル:大規模本番テーブルへの安全なカラム追加

PHP
// 安全:デフォルト値付きでカラムを追加
Schema::table('orders', function (Blueprint $table) {
    $table->decimal('discount', 8, 2)
        ->default(0)
        ->after('total');
});

// 安全:まずnullableにする
Schema::table('shops', function (Blueprint $table) {
    $table->string('phone')->nullable()->after('email');
});

// 危険:カラムの削除 — 2段階で実行
// ステップ1:今回のリリース — コードでカラムの使用を停止
// Schema::table('shops', function (Blueprint $table) {
//     $table->dropColumn('legacy_field');
// });

出力:

TEXT
// 実行成功

7. テーブル構造の変更

(1) カラムの変更

BASH
composer require doctrine/dbal
# 既存カラムの変更に必要
PHP
Schema::table('shops', function (Blueprint $table) {
    $table->string('name', 100)->change();     // 長さの変更
    $table->renameColumn('desc', 'description'); // カラム名の変更
    $table->dropColumn('legacy_field');          // カラムの削除
});

(2) インデックスの変更

PHP
Schema::table('orders', function (Blueprint $table) {
    $table->dropUnique('orders_order_number_unique');
    $table->unique(['tenant_id', 'order_number'], 'orders_tenant_order_unique');
});

(1) ▶ サンプル:ShopMetricsマイグレーションカスタマイズ実践ガイド

PHP
// database/migrations/2024_02_01_add_stripe_to_subscriptions.php
return new class extends Migration
{
    public function up(): void
    {
        Schema::table('subscriptions', function (Blueprint $table) {
            $table->string('stripe_id')->nullable()->unique()->after('id');
            $table->string('stripe_status')->nullable()->after('status');
            $table->timestamp('current_period_start')->nullable()->after('trial_ends_at');
            $table->timestamp('current_period_end')->nullable()->after('current_period_start');
        });
    }

    public function down(): void
    {
        Schema::table('subscriptions', function (Blueprint $table) {
            $table->dropColumn([
                'stripe_id', 'stripe_status',
                'current_period_start', 'current_period_end',
            ]);
        });
    }
};

出力:

TEXT
// 実行成功

8. 総合例:ShopMetrics完全マイグレーションセット

PHP
// ============================================
// 総合例: ShopMetricsコアマイグレーション
// 対象: テーブル, 外部キー, インデックス, ポリモーフィック
// ============================================

// マイグレーション1: plansテーブルの作成
Schema::create('plans', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('slug')->unique();
    $table->decimal('price', 8, 2);
    $table->integer('shop_limit')->default(5);
    $table->integer('order_limit')->default(1000);
    $table->json('features')->nullable();
    $table->boolean('is_active')->default(true);
    $table->timestamps();
});

// マイグレーション2: tenantsテーブルの作成
Schema::create('tenants', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('slug')->unique();
    $table->string('domain')->unique();
    $table->foreignId('plan_id')->nullable()->constrained()->nullOnDelete();
    $table->enum('status', ['active', 'suspended', 'cancelled'])->default('active');
    $table->timestamps();
    $table->softDeletes();
    $table->index(['status', 'created_at']);
});

// マイグレーション3: shopsテーブルの作成
Schema::create('shops', function (Blueprint $table) {
    $table->id();
    $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
    $table->string('name');
    $table->string('slug');
    $table->text('description')->nullable();
    $table->decimal('revenue', 12, 2)->default(0);
    $table->enum('status', ['active', 'suspended', 'closed'])->default('active');
    $table->timestamps();
    $table->softDeletes();
    $table->unique(['tenant_id', 'slug']);
    $table->index(['tenant_id', 'status']);
});

// マイグレーション4: productsテーブルの作成
Schema::create('products', function (Blueprint $table) {
    $table->id();
    $table->foreignId('shop_id')->constrained()->cascadeOnDelete();
    $table->string('name');
    $table->string('sku')->unique();
    $table->decimal('price', 10, 2);
    $table->integer('stock')->default(0);
    $table->boolean('is_active')->default(true);
    $table->timestamps();
    $table->softDeletes();
    $table->index(['shop_id', 'is_active']);
});

// マイグレーション5: ordersテーブルの作成
Schema::create('orders', function (Blueprint $table) {
    $table->id();
    $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
    $table->foreignId('shop_id')->constrained()->cascadeOnDelete();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('order_number')->unique();
    $table->decimal('subtotal', 12, 2);
    $table->decimal('discount', 8, 2)->default(0);
    $table->decimal('total', 12, 2);
    $table->enum('status', ['pending', 'processing', 'completed', 'cancelled'])->default('pending');
    $table->json('metadata')->nullable();
    $table->timestamps();
    $table->index(['tenant_id', 'status']);
    $table->index(['shop_id', 'created_at']);
});

❓ よくある質問

Q migrate:freshmigrate:refreshの違いは何ですか?
A freshはデータベースを削除して再構築します。高速ですが完全なデータ損失を伴います。refreshはマイグレーションをロールバックしてから適用し, down()の後にup()を順番に実行します。開発環境ではfreshが高速ですが, 本番環境ではどちらのコマンドも絶対に使用しないでください。
Q 外部キー制約はパフォーマンスに影響しますか?
A はい。外部キー制約はINSERT, UPDATE, DELETEのたびにチェックが必要です。高同時アクセスのシナリオでは, 外部キー制約を省略し, アプリケーション層でデータの一貫性を確保することを検討してください。
Q 本番環境で安全にマイグレーションを実行するにはどうすればよいですか?
A まずステージング環境でテストし, 新しいカラムにはデフォルト値またはnullableを設定し, カラムの削除は2段階で行い (まずコードの参照を削除し, 次バージョンでカラムを削除), 大規模テーブルのマイグレーションはオフピーク時に実行してください。
Q マイグレーションファイルは修正してもよいですか?
A 未実行のマイグレーション (まだmigrateされていないもの)は自由に修正できます。既に実行されたマイグレーションは修正しないでください。変更を実装するには新しいマイグレーションを作成してください。そうしないと, 他の環境のmigrateコマンドでエラーになります。
Q SQLiteからMySQLへのマイグレーションでどのような互換性の問題がありますか?
A SQLiteは一部のALTER TABLE操作 (カラム型の変更やカラムの削除など)をサポートしていません。開発中にSQLiteを使用するとマイグレーションに制限が生じる可能性があります。本番環境ではMySQLまたはPostgreSQLの使用を推奨します。
Q 既存の大規模テーブルにテーブルロックなしでインデックスを追加するにはどうすればよいですか?
A MySQLではALGORITHM=INPLACE LOCK=NONEを使用します (Laravelは直接サポートしていないためDB::statementが必要)。PostgreSQLではCONCURRENTLYを使用します (Laravel 11+では$table->index('col')->concurrently()がサポートされています)。

📖 まとめ


📝 練習問題

  1. 基本問題 (⭐):ShopMetricsの3テーブル—tenants, plans, subscriptions—のマイグレーションファイルを作成し, 適切な外部キーとインデックスを含め, migrateを実行して正しいことを確認してください。

  2. 応用問題 (⭐⭐):既存のordersテーブルに基づいて, discountカラム (デフォルト値0)と複合インデックス (tenant_id + status)を追加するマイグレーションを作成し, 対応するdown()メソッドを記述してマイグレーションがロールバック可能であることを確認してください。

  3. チャレンジ (⭐⭐⭐):ShopMetrics用のポリモーフィック関連マイグレーションを設計してください—ショップと商品の両方がアドレスを持てるようにし (addressesテーブルでmorphToを使用), morphable_typemorphable_idを使った外部キー設計を実装してください。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%