題材のモデリングとデータ整備 — テーブル・モデル・シーダー
この章では、以降 19 章にわたって使うテーブルとモデルを用意します。
ここで払う初期投資は、あとの章すべてに効いてきます。在庫を別テーブルに分けておくと第14章で行ロックが素直に書けます。注文明細に単価を持たせておくと、商品価格を変えても過去の注文が壊れません。逆に、ここで手を抜くと後の章で毎回テーブル定義に戻ることになります。
注文システムのテーブル
4 つのテーブルを作ります。
この形にした理由を 3 つ説明します。テーブル設計の一般論ではなく、あとの章でこの構造が必要になるという理由です。
在庫を products に持たせない
products に stock_quantity カラムを足せば、テーブルは 3 つで済みます。それでも分けるのは、商品情報と在庫数で更新の性質が違うからです。
商品名や説明はめったに変わりません。在庫数は注文のたびに変わります。第14章では、在庫を減らすときに該当行をロックして他のリクエストを待たせます。このとき products にロックをかけると、同じ商品の商品情報を書き換える処理まで巻き込みます。注文が集中しているあいだ、価格の改定や説明文の修正が進まなくなります。在庫だけを別テーブルに置いておけば、ロックの範囲を在庫行に閉じ込められます。
注文明細に単価を持たせる
order_items には product_id があるので、価格は products から引けそうに見えます。しかしそれをすると、商品の価格を変えた瞬間に過去の注文金額まで変わります。
注文は「その時点でいくらだったか」を記録するものです。だから注文した時点の単価を order_items にコピーして持ちます。同じ理由で、orders にも合計金額 total_amount を持たせます。
金額を整数で持つ
price も unit_price も total_amount も、型は integer で単位は円です。float は使いません。
浮動小数点数は 10 進の小数を正確に表現できません。0.1 + 0.2 が 0.3 にならないのと同じ理由で、金額の計算で 1 円のずれが生じます。日本円のように補助単位を扱わない通貨なら整数の円で、ドルのようにセントがある通貨なら整数のセントで持ちます。
本格的な会計処理を伴うシステムでは、DB 側で decimal 型を使う選択もあります。テーブル設計そのものの考え方はデータベース設計ガイドが扱っているので、そちらも参照してください。この連載では話を単純に保つため整数の円で通します。
マイグレーションを書く
Laravel はテーブルの定義を PHP のコードで管理します。これがマイグレーションです。
php artisan make:migration create_products_table
生成されたファイルに定義を書きます。
<?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('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->integer('price');
$table->timestamps();
});
}
public function down(): void
{
Schema::drop('products');
}
};
冒頭の return new class extends Migration は PHP の匿名クラスという書き方で、名前を付けずにその場でクラスを定義しています。マイグレーションのファイルは同じディレクトリに何十個も並ぶため、クラス名の衝突を避ける目的でこの形になっています。
$table->id() が主キー、$table->timestamps() が created_at と updated_at を作ります。up() が適用、down() が巻き戻しです。
外部キー制約を付ける
stocks は products を参照します。
public function up(): void
{
Schema::create('stocks', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->unique()->constrained()->cascadeOnDelete();
$table->integer('quantity');
$table->timestamps();
});
}
foreignId('product_id') は UNSIGNED BIGINT のカラムを作ります。続く constrained() は、カラム名から参照先のテーブルとカラムを推測して外部キー制約を張ります。product_id なら products テーブルの id です。
unique() を付けているのは、1 つの商品に在庫レコードが 1 行しかない設計だからです。cascadeOnDelete() は、商品が削除されたら在庫行も一緒に消す指定です。
外部キー制約を付ける意味は、アプリケーションのバグがあってもデータベースが不整合を拒否してくれることです。存在しない product_id を持つ在庫行は、制約があれば INSERT の時点で失敗します。制約がなければ黙って入り、後日「商品が見つからない在庫行」として発覚します。
命名規約から外れる場合は参照先を明示できます。
$table->foreignId('created_by')->constrained(table: 'users');
orders と order_items も同じ要領で作ります。
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('customer_email');
$table->string('status')->default('pending');
$table->integer('total_amount');
$table->timestamps();
});
Schema::create('order_items', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained();
$table->integer('quantity');
$table->integer('unit_price');
$table->timestamps();
});
order_id には cascadeOnDelete() を付けて、注文が消えたら明細も消えるようにします。product_id には付けません。注文された商品を削除できてしまうと、過去の注文明細が消えるからです。制約があることで、商品の削除が失敗します。それが正しい挙動です。
適用します。
php artisan migrate
このコマンドは、いま書いた 4 つに加えて Laravel が最初から持っているマイグレーションも適用します。users (会員)、cache (キャッシュ)、jobs (キュー) などのテーブルです。それぞれ第11章・第18章・第16章で使うので、いまの時点では「作られている」ことだけ知っておけば足ります。
Eloquent モデルを作る
テーブルに対応するクラスを作ります。
php artisan make:model Product
php artisan make:model Stock
php artisan make:model Order
php artisan make:model OrderItem
Laravel はクラス名の複数形をテーブル名として扱います。Product は products、OrderItem は order_items です。命名規約に従っていればテーブル名の指定は要りません。
注文の状態を enum で表す
orders.status は文字列カラムですが、入る値は決まっています。PHP の backed enum で表現します。
<?php
namespace App\Enums;
enum OrderStatus: string
{
case Pending = 'pending';
case Confirmed = 'confirmed';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
}
モデル側で、このカラムを enum として扱うよう指定します。
次のコードに出てくる #[Fillable([...])] は、PHP 8 のアトリビュートという構文です。クラスやメソッドに付ける印で、フレームワークがそれを読み取って動きを変えます。# で始まりますが行コメントではありません。#[ から対応する ] までが 1 つの構文で、コメントとして無視されることはありません。
<?php
namespace App\Models;
use App\Enums\OrderStatus;
use Database\Factories\OrderFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['customer_email', 'status', 'total_amount'])]
class Order extends Model
{
/** @use HasFactory<OrderFactory> */
use HasFactory;
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'status' => OrderStatus::class,
];
}
}
casts() メソッドに書くと、DB から読んだ 'pending' という文字列が OrderStatus::Pending として取り出せます1。保存するときも enum のまま渡せます。
$order = Order::find(1);
$order->status; // OrderStatus::Pending (enum)
$order->status === OrderStatus::Pending; // true
$order->status = OrderStatus::Confirmed; // enum のまま代入できる
$order->save();
enum にしておくと、タイポが実行時エラーになります。'confirm' と 'confirmed' を間違えても、文字列のままなら気づけません。
mass assignment を塞ぐ
Order::create($request->all()) のように、配列をまとめてモデルに渡す操作を mass assignment と呼びます。便利ですが、そのままでは危険です。リクエストの JSON に "is_admin": true を混ぜられたら、そのカラムまで書き込まれてしまいます。
Laravel はこれを既定で防いでいます。どの属性を mass assignment で受け付けるかを宣言しないと、create メソッド自体が使えません2。
宣言は #[Fillable] 属性で行います。
#[Fillable(['customer_email', 'status', 'total_amount'])]
class Order extends Model
{
// ...
}
#[Unguarded]
class Order extends Model
{
// ...
}
#[Unguarded] を付けるとすべての属性が mass assignment を通ります。Laravel が既定で用意している防御を自分で外す指定なので、この連載では使いません。
$fillable プロパティになっていますLaravel 13 のドキュメントと標準の User モデルは #[Fillable([...])] の属性形式を使います。少し前の記事に出てくる protected $fillable = [...] はプロパティ形式で、書く場所が違うだけで目的は同じです。同様に $hidden は #[Hidden([...])]、$guarded = [] は #[Unguarded] に対応します。
残る 3 つのモデルも同じ形で書きます。mass assignment を使うモデルには必ず #[Fillable] が要ります。 宣言が無いと create() を呼んだ時点で例外になります。
#[Fillable(['product_id', 'quantity', 'unit_price'])]
class OrderItem extends Model
{
/** @use HasFactory<OrderItemFactory> */
use HasFactory;
}
#[Fillable(['product_id', 'quantity'])]
class Stock extends Model
{
/** @use HasFactory<StockFactory> */
use HasFactory;
}
OrderItem の #[Fillable] に order_id が無いのは意図的です。第6章で $order->items()->create([...]) の形で作るとき、order_id はリレーション側が入れるためです。ここに書いておくと、リクエスト由来の配列で別の注文に明細をぶら下げられる余地が生まれます。
リレーションを定義する
モデル同士のつながりを書きます。
use Illuminate\Database\Eloquent\Relations\HasMany;
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
use Illuminate\Database\Eloquent\Relations\HasOne;
public function stock(): HasOne
{
return $this->hasOne(Stock::class);
}
これで $order->items や $product->stock でたどれます。リレーションを使った取得は第7章と第8章で本格的に扱います。そこで N+1 問題にも触れます。
ファクトリとシーダーで開発データを用意する
テーブルとモデルができても、中身が空では API を動かせません。ファクトリを使うと、それらしいデータを好きなだけ作れます。
php artisan make:factory ProductFactory
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Product>
*/
class ProductFactory extends Factory
{
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->words(3, true),
'description' => fake()->sentence(),
'price' => fake()->numberBetween(500, 50000),
];
}
}
definition() が既定の状態を返します。fake() はダミーデータを生成するヘルパです。
モデル側に HasFactory トレイトを付けると、Product::factory() で呼び出せるようになります。標準の User モデルにも付いています。
use Database\Factories\ProductFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
#[Fillable(['name', 'description', 'price'])]
class Product extends Model
{
/** @use HasFactory<ProductFactory> */
use HasFactory;
}
/** @use HasFactory<ProductFactory> */ の docblock は型を静的解析ツールに伝えるためのものです。標準の User モデルも同じ形で書かれています。
Order / OrderItem / Stock も同じ要領でファクトリを作り、HasFactory を付けておきます。この章の Order モデルの例が OrderFactory を import しているのはそのためです。
OrderFactory は第10章以降のテストで使うので、ここで作っておきます。
use App\Enums\OrderStatus;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'customer_email' => fake()->unique()->safeEmail(),
'status' => OrderStatus::Pending,
'total_amount' => fake()->numberBetween(1000, 100000),
];
}
orders の 3 列はどれも NOT NULL なので、すべて埋める必要があります。1 つでも欠けるとテストで create() した瞬間にエラーになります。
特定の状態を作る
「在庫切れの商品」のように、既定と違う状態が欲しいことがあります。state メソッドを定義します。
public function definition(): array
{
return [
'product_id' => Product::factory(),
'quantity' => fake()->numberBetween(1, 100),
];
}
/**
* 在庫切れの状態
*/
public function soldOut(): static
{
return $this->state(fn (array $attributes) => [
'quantity' => 0,
]);
}
'product_id' => Product::factory() と書くと、在庫を作るときに紐づく商品も一緒に作られます。
使うときはこう書きます。
Stock::factory()->create(); // 在庫あり
Stock::factory()->soldOut()->create(); // 在庫ゼロ
この soldOut() は第14章で在庫切れのテストを書くときに使います。テストのたびに quantity を 0 で上書きする代わりに、意図を名前で表せます。
シーダーでまとめて投入する
シーダーは、ファクトリを呼んで開発用のデータを一括で作る仕組みです。
<?php
namespace Database\Seeders;
use App\Models\Product;
use App\Models\Stock;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
Product::factory()
->count(20)
->has(Stock::factory())
->create();
}
}
has(Stock::factory()) で、商品 1 件ごとに在庫レコードも作ります。
php artisan db:seed
テーブルを作り直してからシーダーを流したいときは、1 コマンドで済みます。
php artisan migrate:fresh --seed
本番で効く注意点
migrate:fresh を本番で実行しない
migrate:fresh はすべてのテーブルを削除してから作り直します。ローカルでは便利ですが、本番で実行するとデータが消えます。名前が migrate で始まるので、php artisan migrate のつもりでタブ補完を確定してしまう事故が起こりえます。
本番で流すのは php artisan migrate --force だけです。--force は「本番環境でも実行してよい」という確認を省略するフラグで、デプロイスクリプトから呼ぶときに必要になります。第21章のデプロイ手順で再度触れます。
シーダーを本番で流さない
シーダーが作るのは開発用のダミーデータです。本番のデータベースに fake() が生成した商品が 20 件並ぶことになります。
対策は、シーダーの中で環境を確認するか、そもそもデプロイ手順に db:seed を入れないことです。マスタデータの投入が必要なら、ダミーデータのシーダーとは別のクラスに分けます。
マイグレーションは「前進のみ」で設計する
down() メソッドがあるので巻き戻せそうに見えますが、本番では期待しないほうが安全です。カラムを削除するマイグレーションを巻き戻しても、そのカラムに入っていたデータは戻りません。
本番で問題が起きたときの対処は、巻き戻しではなく「修正するマイグレーションを新しく足す」ことです。down() はローカルで試行錯誤するときに使うもの、と考えておくと設計を誤りません。
この方針は、カラムを削除するときの手順にも影響します。「新しいカラムを足す」「両方に書く」「読み取りを新しいほうに切り替える」「古いカラムを消す」のように、段階を分けて進めます。1 回のデプロイで消すと、デプロイ中の一瞬だけ「新しいコード + 古いスキーマ」または「古いコード + 新しいスキーマ」の組み合わせが生じ、そこでエラーが出ます。
削除の連鎖を意図して設計する
cascadeOnDelete() は便利ですが、消える範囲を確認してから付けます。order_items の product_id にこれを付けていたら、商品を 1 つ消したときにその商品を含む過去の注文明細がすべて消えます。売上の集計が合わなくなり、原因の特定にも時間がかかります。
この連載で cascadeOnDelete() を付けたのは 2 箇所です。stocks.product_id (商品が消えたら在庫行も消す) と order_items.order_id (注文が消えたら明細も消す) で、どちらも親がなければ意味を持たないデータです。対して order_items.product_id には付けていません。
その結果、一度でも注文された商品は削除できなくなります。order_items の外部キー制約が先に削除を拒否するので、stocks の連鎖削除にも到達しません。削除が失敗することが正しい挙動です。過去の注文を保ったまま商品を扱わなくしたいなら、削除ではなく販売停止フラグや論理削除で表現します。
まとめ
- 在庫を別テーブルに分けるのは、第14章で在庫行だけをロックするため
- 注文明細に単価をコピーするのは、商品価格を変えても過去の注文を変えないため
- 金額は整数で持つ。
floatは 10 進の小数を正確に表せない - Laravel 13 では mass assignment の許可を
#[Fillable]属性で宣言する。#[Unguarded]で全開放しない casts()メソッドで backed enum を指定すると、状態を文字列でなく型で扱える- ファクトリの state メソッドは、テストで作りたい状況に名前を付ける手段になる
次に読む
次章 ルーティング設計と最初のエンドポイント では、いよいよ最初のエンドポイントを作ります。php artisan install:api を実行して routes/api.php を用意し、apiResource() でルートをまとめて定義します。URL に何を出すか、{product} をどうやってモデルに変換するかも、ここで決めます。
練習問題
orders テーブルに total_amount を持たせず、注文のたびに order_items から SUM で計算する設計にはどんな問題がありますか
解答例
計算そのものは正しく動きます。問題は 2 つの方向から出ます。
性能: 注文一覧を返す API で、注文 100 件それぞれについて明細の合計を計算することになります。第8章で扱う N+1 問題と同じ構造で、件数に比例して遅くなります。
正しさ: より本質的なのはこちらです。合計金額には、明細の単純な合計では表せない要素が入ります。送料、クーポン割引、キャンペーンの端数調整などです。これらを後から追加するとき、SUM で計算する設計だと「どこに足すか」の置き場所がありません。
total_amount を持つのは冗長に見えますが、「この注文はいくらだったか」という事実を記録していると考えると自然です。明細の合計と一致するかどうかは、その時点のビジネスルールが決めることです。
次のモデルには問題があります。指摘してください
use Illuminate\Database\Eloquent\Attributes\Unguarded;
#[Unguarded]
class User extends Authenticatable
{
protected function casts(): array
{
return [
'password' => 'hashed',
];
}
}
解答例
#[Unguarded] によって、すべての属性が mass assignment を通ります。
users テーブルに is_admin や role のような権限を表すカラムがあれば、会員登録の API に次の JSON を送るだけで管理者になれます。
{
"name": "テスト",
"email": "test@example.com",
"password": "password",
"is_admin": true
}
登録処理が User::create($request->validated()) と書かれていて、バリデーションルールに is_admin が含まれていなければ防げます。しかし防御がバリデーションルールの書き方だけに依存する状態です。ルールを 1 つ書き忘れた瞬間に穴が開きます。
#[Fillable(['name', 'email', 'password'])] と宣言しておけば、リクエストに何が混ざっていてもそれ以外は捨てられます。Laravel が既定で mass assignment を禁じているのは、この防御を外させないためです。
なお、この属性に含まれない値が黙って捨てられるのは分かりにくいという面もあります。開発中だけ例外を投げさせたい場合は、AppServiceProvider の boot メソッドで Model::preventSilentlyDiscardingAttributes() を呼ぶ方法があります3。