メインコンテンツまでスキップ

Eloquentモデルの責務 — 書いていい仕事と書くべきでない仕事

第8章では、Controllerに業務ロジックを詰め込んだFat Controllerと、その処理をそのままモデルに移しただけのFat Modelを見ました。 Fat Modelの問題は、Eloquentモデルという仕組み自体が悪いということではありません。 Eloquentが提供するリレーション・アクセサ・キャストといった機能を使わず、複数の関心事を1つのメソッドに詰め込んだことが問題でした。 この章では、Eloquentモデルが本来担うべき責務と、担うべきでない責務を分けて見ていきます。

Eloquentモデルの本来の責務

Eloquentモデルは、Active Recordパターンの実装です1。 1つのモデルインスタンスはデータベースの1行に対応し、その行が持つデータをどう表現し、どう解釈するかに責務を持ちます。 具体的には、次の4つがEloquentモデル自身の責務です。

リレーションの定義

注文(Order)が複数の注文明細(OrderItem)を持つという関係は、モデル自身が知っておくべき情報です。

✅ Good: リレーションの定義
class Order extends Model
{
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
}

$order->itemsと書けば、関連するOrderItemをまとめて取得できます。 これは「このOrderはどのOrderItemと結びついているか」という、Order自身のデータについての知識です。

アクセサとキャスト

保存された値をどう解釈して返すかも、モデル自身の責務です。 第1部で扱った例では、注文の合計金額をOrder::calculateTotal()というメソッドでOrderクラス自身が計算していました。 Eloquentモデルでも、同じ考え方をアクセサで表現できます。

✅ Good: 合計金額をアクセサで表現する
class Order extends Model
{
protected function total(): Attribute
{
return Attribute::make(
get: fn (mixed $value, array $attributes) => $this->items
->sum(fn (OrderItem $item) => $item->price * $item->quantity),
);
}
}

$order->totalと書けば、明細から計算された合計金額を取得できます2。 この計算は「自分が持つ明細データをどう解釈するか」という、Orderのデータ表現の一部です。

保存された値の型をどう扱うかも同様です。 第2章では、決まった選択肢しか取らない値を表すためにOrderStatusというbacked enumを定義しました。 注文のステータスをこのenumとして扱いたい場合、casts()メソッドで指定します。

✅ Good: ステータスをenumにキャストする
class Order extends Model
{
protected function casts(): array
{
return [
'status' => OrderStatus::class,
];
}
}

こう書くと、$order->status'pending'のような文字列ではなく、OrderStatusのインスタンスとして扱えます3。 保存されている値をどの型として読み書きするかは、Order自身のデータ表現に属する判断です。

ローカルスコープ

よく使う検索条件を再利用可能な形にすることも、モデルの責務に含まれます。

✅ Good: ローカルスコープ
class Order extends Model
{
public function scopeForCustomer(Builder $query, string $email): void
{
$query->where('customer_email', $email);
}
}

Order::forCustomer($email)->get()のように、scopeという接頭辞を除いた名前で呼び出せます4。 これも「自分のテーブルをどう絞り込むか」という、Orderのデータについての知識です。

モデルに書くべきでない責務

一方で、第8章のFat Model例に戻ってみます。

❌ Bad: 複数の関心事を持つメソッド(第8章の再掲)
class Order extends Model
{
public static function placeOrder(array $items, string $customerEmail): self
{
$total = 0;
foreach ($items as $item) {
$total += $item['price'] * $item['quantity'];
}

foreach ($items as $item) {
$stock = DB::table('stocks')->where('name', $item['name'])->value('quantity');
if ($stock < $item['quantity']) {
throw new RuntimeException('在庫が不足しています');
}
}

$order = self::create([
'customer_email' => $customerEmail,
'total' => $total,
]);

Mail::raw("ご注文ありがとうございます。合計{$total}円です。", function ($message) use ($customerEmail) {
$message->to($customerEmail)->subject('注文確認');
});

return $order;
}
}

placeOrder()が行っていることを分解すると、次のようになります。

  • 合計金額を計算する
  • 在庫テーブル(stocks)を確認する
  • 注文レコードを作成する
  • メールを送信する

このうち、合計金額の計算は、前半で見たアクセサに切り出せる処理です。 一方、在庫確認とメール送信は、Orderというデータの表現とは関係がありません。 在庫確認は在庫という別の関心事についての判断であり、メール送信は通知という別の関心事の実行です。 どちらも、Orderの行が持つデータをどう解釈するかとは無関係な処理です。

判断基準

Eloquentモデルに書いていい処理かどうかは、次の問いで判断できます。

その処理は、このモデル自身が持つデータの表現・解釈だけで完結するか。

リレーション・アクセサ・キャスト・ローカルスコープは、いずれも「自分の行のデータをどう見せるか」という問いに答えるものです。 一方、在庫確認は在庫テーブルという他のデータへの問い合わせであり、メール送信は外部のメール配信の仕組みへの依頼です。 どちらも、Order自身のデータの解釈を超えて、複数の関心事を調整する処理です。

この「複数の関心事を調整する処理」の置き場所が、第10章で扱うサービスクラスです。

まとめ

  • Eloquentモデルの責務は、自分が表す行のデータをどう表現し解釈するかに閉じている
  • リレーション・アクセサ/ミューテタ・キャスト・ローカルスコープは、いずれもモデル自身のデータについての知識であり、モデルに書いてよい
  • 在庫確認やメール送信のように、複数の関心事にまたがる処理は、モデルの責務を超える
  • 判断基準は「その処理がモデル自身のデータの表現・解釈だけで完結するか」

次に読む

次章では、モデルの責務を超える処理をどこに置くかを扱います。 Controllerが肥大化したときに、Serviceクラスへ処理を切り出す判断基準を見ていきます。

練習問題

次の処理は、Eloquentモデルに書いてよい責務でしょうか。理由とともに答えてください
  1. 注文のitemsリレーションから、注文点数の合計を計算して返すアクセサ
  2. 注文が確定済みかどうかを、他の注文履歴テーブルと突き合わせて判定する処理
  3. 注文ステータスをenumにキャストする設定

解答例

  1. 書いてよい: 自分が持つリレーションのデータを解釈しているだけなので、モデル自身の責務です
  2. 書くべきでない: 他のテーブルとの突き合わせは、Order自身のデータの解釈を超えた判断です
  3. 書いてよい: 保存された値の型をどう扱うかは、モデル自身の責務です

Footnotes

  1. 出典: Laravel公式ドキュメント — Eloquent: Getting Started。データベースの各テーブルに対応する「モデル」を通じてデータベースとやり取りする、Active Recordパターンに基づく設計であることが説明されています。

  2. 出典: Laravel公式ドキュメント — Eloquent: Mutators & CastingAttribute::make()によるアクセサ定義が現在の推奨パターンです。

  3. 出典: Laravel 11 リリースノートcasts()メソッドによるキャスト定義は、Laravel 11で導入された書き方です。

  4. 出典: Laravel公式ドキュメント — Eloquent: Getting Startedscopeを接頭辞にしたメソッドでローカルスコープを定義します。