Laravelデータベースとマイグレーションシステム
マイグレーションはLaravelの「データベースバージョン管理」です—Gitがコードを管理するようにデータベース構造を管理するため, チームはテーブル構造の同期ズレに悩まされることがなくなります。
1. 学ぶ内容
- マイグレーションファイルの作成と実行:make:migration / migrate / rollback
- Schema Builder:カラム型, インデックス, 外部キー制約
- マルチテナントテーブル設計:tenants/users/subscriptions/plans/orders
- マイグレーション戦略:本番環境のゼロダウンタイム安全マイグレーション
- MySQLとPostgreSQLのマイグレーション差異の処理
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を実行すれば, データベース構造は完全に一致します。
php artisan make:migration add_discount_to_orders_table
# タイムスタンプ付きマイグレーションファイルを作成
php artisan migrate
# 未実行のマイグレーションを順番に実行
(3) 成果
Charlieが手動SQLをマイグレーションに置き換えた後, 新しいフィールドにはデフォルト値の指定が必須となり (コードレビューで検出可能), Bobがローカル変更をGitにプッシュすればCharlieは1コマンドで同期でき, インシデントはゼロになりました。
3. マイグレーションの基礎
(1) マイグレーションファイルの作成
# マイグレーションを作成
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) マイグレーションファイルの構造
// 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) ▶ サンプル:マイグレーションの作成と実行
# マイグレーションを作成
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
出力:
# コマンド実行成功
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) インデックスと制約
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マルチテナント外部キー設計
// 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']);
});
出力:
// 実行成功
5. マルチテナントテーブル設計
(1) ShopMetricsコアテーブル
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完全ユーザーテーブルマイグレーション
// 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');
});
出力:
// 実行成功
6. 本番マイグレーション戦略
(1) 安全なマイグレーションの原則
| 原則 | 説明 | 違反時の結果 |
|---|---|---|
| 新規追加にはデフォルト値必須 | ->default(0)または->nullable() |
既存レコードでエラー |
| カラム削除は先にコードを削除 | まずカラムの使用を停止し, 次バージョンで削除 | コードが存在しないカラムを参照 |
after()でカラム位置を指定 |
テーブル再構築を減らす | テーブルロック時間が長すぎる |
| マイグレーションをトランザクション内で | withinTransactionプロパティ |
部分的成功, 部分的失敗 |
(2) MySQLとPostgreSQLの違い
| 機能 | MySQL | PostgreSQL |
|---|---|---|
| カラムデフォルト値 | 即座に追加 | テーブルの上書きが必要 |
| JSONカラム | json() |
json() + jsonb() |
| 列挙型 | enum() |
string + CHECKを推奨 |
| フルテキストインデックス | fullText() |
fullText() + GIN |
| 外部キーチェック | 一時的に無効化可能 | 厳密チェック |
(1) ▶ サンプル:大規模本番テーブルへの安全なカラム追加
// 安全:デフォルト値付きでカラムを追加
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');
// });
出力:
// 実行成功
7. テーブル構造の変更
(1) カラムの変更
composer require doctrine/dbal
# 既存カラムの変更に必要
Schema::table('shops', function (Blueprint $table) {
$table->string('name', 100)->change(); // 長さの変更
$table->renameColumn('desc', 'description'); // カラム名の変更
$table->dropColumn('legacy_field'); // カラムの削除
});
(2) インデックスの変更
Schema::table('orders', function (Blueprint $table) {
$table->dropUnique('orders_order_number_unique');
$table->unique(['tenant_id', 'order_number'], 'orders_tenant_order_unique');
});
(1) ▶ サンプル:ShopMetricsマイグレーションカスタマイズ実践ガイド
// 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',
]);
});
}
};
出力:
// 実行成功
8. 総合例:ShopMetrics完全マイグレーションセット
// ============================================
// 総合例: 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']);
});
❓ よくある質問
migrate:freshとmigrate:refreshの違いは何ですか?freshはデータベースを削除して再構築します。高速ですが完全なデータ損失を伴います。refreshはマイグレーションをロールバックしてから適用し, down()の後にup()を順番に実行します。開発環境ではfreshが高速ですが, 本番環境ではどちらのコマンドも絶対に使用しないでください。migrateコマンドでエラーになります。ALTER TABLE操作 (カラム型の変更やカラムの削除など)をサポートしていません。開発中にSQLiteを使用するとマイグレーションに制限が生じる可能性があります。本番環境ではMySQLまたはPostgreSQLの使用を推奨します。ALGORITHM=INPLACE LOCK=NONEを使用します (Laravelは直接サポートしていないためDB::statementが必要)。PostgreSQLではCONCURRENTLYを使用します (Laravel 11+では$table->index('col')->concurrently()がサポートされています)。📖 まとめ
- マイグレーションはデータベースのバージョン管理であり, チームがマイグレーションファイルを共有して構造の一貫性を確保します
- Schema BuilderはPHPコードでテーブル構造を記述し, カラム型, インデックス, 外部キーをサポートします
- 外部キー制約は参照整合性を確保しますが, 高同時アクセスシナリオではアプリケーション層での確保を検討してください
- マルチテナントテーブル設計は
tenant_id外部キーでデータ分離を実現します - 本番マイグレーションは安全に:新しいカラムにはデフォルト値を設定し, カラムの削除は2段階で
- migrate:freshは開発用, 本番では「migrate」のみを使用
📝 練習問題
-
基本問題 (⭐):ShopMetricsの3テーブル—tenants, plans, subscriptions—のマイグレーションファイルを作成し, 適切な外部キーとインデックスを含め,
migrateを実行して正しいことを確認してください。 -
応用問題 (⭐⭐):既存の
ordersテーブルに基づいて,discountカラム (デフォルト値0)と複合インデックス (tenant_id+status)を追加するマイグレーションを作成し, 対応するdown()メソッドを記述してマイグレーションがロールバック可能であることを確認してください。 -
チャレンジ (⭐⭐⭐):ShopMetrics用のポリモーフィック関連マイグレーションを設計してください—ショップと商品の両方がアドレスを持てるようにし (
addressesテーブルでmorphToを使用),morphable_typeとmorphable_idを使った外部キー設計を実装してください。



