【実務完全ガイド】LaravelのEloquent設計――リレーション、スコープ、N+1対策、集約、DTO/Resourceとの分離、保守しやすいモデル設計
この記事で学べること(要点)
- Eloquentを「便利だから使う」から「壊れにくく設計して使う」へ進める考え方
- リレーション設計(
belongsTo/hasMany/belongsToMany)と命名の基本 - N+1問題を避ける実践パターンと、
with/withCount/loadMissingの使い分け - ローカルスコープ、アクセサ/ミューテータ、キャスト、値オブジェクトの整理
- Fat Model を避けつつ、Service / Action / DTO / API Resource と責務分離する方法
- 集約・集計・検索条件・ページング・更新処理を読みやすく保つ書き方
- テストしやすいEloquent設計と、アクセシブルな一覧・詳細表示へのつなぎ方
想定読者(だれが得をする?)
- Laravel 初〜中級エンジニア:Eloquentは書けるが、モデルが大きくなって整理に困っている方
- テックリード:モデル・サービス・API Resource の責務分担をチームで揃えたい方
- QA/保守担当:一覧画面やAPIの不具合原因が「どこで整形しているか分からない」状態を減らしたい方
- デザイナー/フロント担当:画面表示に必要なデータが、安定した形で提供される基盤を整えたい方
アクセシビリティレベル:★★★★☆
この記事の主題はORM設計ですが、一覧・詳細・集計結果の出し方がUIの理解しやすさに直結するため、画面側での見出し構造、件数表示、色に依存しない状態表現、読み上げで解釈しやすいデータ整形も意識して解説します。
1. はじめに:Eloquentは便利ですが、便利さだけで書くとすぐ複雑になります
LaravelのEloquentはとても書きやすく、最初は「モデルに書けば何とかなる」と感じやすいです。実際、小規模な画面やAPIならすぐに動きます。ですが、画面が増え、条件分岐が増え、集計や権限やAPIレスポンス整形が混ざり始めると、次のような悩みが出てきます。
- モデルが巨大になって、どこに何があるか分からない
- 一覧は速いのに詳細だけ遅い
- APIレスポンスの整形がモデルに混ざっている
- 同じ検索条件がコントローラごとにコピペされている
- N+1問題がたびたび再発する
- テストしようとしても前提データが多く、原因特定に時間がかかる
Eloquentは悪くありません。むしろ強力です。大切なのは、Eloquentに何を任せて、何を任せないかを決めることです。この記事では、実務で保守しやすいEloquent設計の型を、順番に整理していきます。
2. 基本方針:モデルに置くもの、置かないものを先に決める
まず方針を決めると、迷いが減ります。
モデルに置きやすいもの
- テーブルとの対応
- リレーション
- スコープ(再利用しやすい検索条件)
- キャスト
- 小さなドメインルール
- 状態判定(公開中か、期限切れか など)
モデルに置かない方がよいもの
- 長い集計ロジック
- 外部API連携
- メール送信
- 画面専用のレスポンス整形
- 複数モデルをまたぐ大きな業務処理
- 画面やAPIごとにしか使わない表示ロジック
つまり、モデルは「データとその周辺の小さなルール」に集中させると安定します。重い処理や画面都合の整形は、Service / Action / DTO / Resource に逃がした方が読みやすくなります。
3. リレーション設計:名前と方向が分かりやすいほど後で楽になります
例として、ブログの User, Post, Comment, Tag を考えます。
3.1 基本のリレーション
// app/Models/Post.php
class Post extends Model
{
public function user()
{
return $this->belongsTo(User::class);
}
public function comments()
{
return $this->hasMany(Comment::class);
}
public function tags()
{
return $this->belongsToMany(Tag::class);
}
}
// app/Models/User.php
class User extends Model
{
public function posts()
{
return $this->hasMany(Post::class);
}
}
3.2 命名の基本
- 単数:
belongsTo,hasOne - 複数:
hasMany,belongsToMany - 意味が明確な名前にする
user()は分かりやすいowner()の方が文脈に合うならowner()でも良い
- テーブル名や外部キーの規約から外れる場合は、必ず明示する
public function owner()
{
return $this->belongsTo(User::class, 'owner_id');
}
リレーション名は、画面やAPIの読みやすさにも影響します。後から見ても意味が伝わる名前を優先すると保守が楽です。
4. N+1問題:最初に身につけたいEloquentの実務力
Eloquentで最もよく起きる性能問題がN+1です。
4.1 悪い例
$posts = Post::latest()->take(20)->get();
foreach ($posts as $post) {
echo $post->user->name;
}
このコードは、投稿一覧を取った後に、投稿ごとに user を取りにいくため、SQLがどんどん増えます。
4.2 良い例
$posts = Post::with('user')->latest()->take(20)->get();
foreach ($posts as $post) {
echo $post->user->name;
}
4.3 件数だけ欲しいなら withCount
$posts = Post::with('user')
->withCount('comments')
->latest()
->paginate(20);
4.4 表示に必要な列だけに絞る
$posts = Post::query()
->select(['id', 'user_id', 'title', 'published_at'])
->with(['user:id,name'])
->withCount('comments')
->latest()
->paginate(20);
ポイント
- 一覧表示では、全部の列はほぼ不要です
- 関連先も
id,nameのように必要列だけに絞ると軽くなります - N+1対策は「あとで最適化」ではなく、「一覧を書くときの習慣」にすると再発が減ります
5. with / load / loadMissing の使い分け
似たメソッドが多いので、使い分けを整理しておくと便利です。
with
最初のクエリ時に一緒に読み込む
Post::with('user')->get();
load
取得済みモデルに対して追加で読み込む
$post->load('comments');
loadMissing
読み込まれていない場合だけ読み込む
$post->loadMissing('user');
実務では、
- 一覧や詳細の最初のクエリは
with - 条件によって追加したいときは
load - 共通処理や再利用コードでは
loadMissing
が扱いやすいです。
6. スコープ:検索条件をコントローラから追い出す
同じ where 条件がコントローラやAPIに散ると、修正漏れが起きやすくなります。そこで、モデルのローカルスコープが効きます。
6.1 例:公開済み投稿
// app/Models/Post.php
public function scopePublished($query)
{
return $query->whereNotNull('published_at')
->where('published_at', '<=', now());
}
使う側
$posts = Post::published()->latest()->paginate(20);
6.2 例:キーワード検索
public function scopeKeyword($query, ?string $keyword)
{
if (!$keyword) {
return $query;
}
return $query->where(function ($q) use ($keyword) {
$q->where('title', 'like', "%{$keyword}%")
->orWhere('body', 'like', "%{$keyword}%");
});
}
$posts = Post::published()
->keyword($request->string('q')->toString())
->latest()
->paginate(20);
ポイント
- スコープは「再利用される条件」に絞る
- 画面専用の複雑な並び替えまで全部スコープに入れると、逆に読みにくくなることがあります
- よく使う条件だけを、短く分かりやすい名前で切り出すのが良いです
7. アクセサ・ミューテータ・キャスト:小さな整形はモデルで吸収する
7.1 キャスト
protected function casts(): array
{
return [
'published_at' => 'datetime',
'is_featured' => 'boolean',
'meta' => 'array',
];
}
キャストを使うと、コントローラやビューでの Carbon::parse() や json_decode() が減り、読みやすくなります。
7.2 状態判定メソッド
public function isPublished(): bool
{
return !is_null($this->published_at) && $this->published_at->isPast();
}
このような小さな状態判定は、モデルにあると自然です。画面やAPIで同じ条件を何度も書かずに済みます。
7.3 アクセサはやりすぎない
例えば「表示用のラベル」を返す程度なら便利です。
protected function statusLabel(): Attribute
{
return Attribute::make(
get: fn () => $this->isPublished() ? '公開中' : '下書き'
);
}
ただし、HTMLを返したり、複雑な文言ロジックまで入れると、画面都合が強くなりすぎます。表示専用の整形は ViewModel や Resource に逃がす方が安全です。
8. appends, hidden, fillable, guarded:地味ですが重要です
8.1 fillable
protected $fillable = [
'title',
'body',
'published_at',
];
大量代入の安全性を守るため、明示しておく方が安心です。
8.2 hidden
APIレスポンスや配列化で不要なものは隠します。
protected $hidden = [
'password',
'remember_token',
];
8.3 appends
アクセサを自動で配列化したいときに使いますが、増やしすぎると重くなりやすいです。APIごとに必要な形が違うなら、Resourceで整えた方が管理しやすいです。
9. API Resource と DTO:画面やAPI向けの整形はモデルから分離する
モデルに「このAPI専用の項目」や「画面用の整形」を増やすと、責務が崩れます。そこで API Resource や DTO が役立ちます。
9.1 API Resourceの例
// app/Http/Resources/PostResource.php
class PostResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => (string) $this->id,
'title' => $this->title,
'author' => [
'id' => (string) $this->user->id,
'name' => $this->user->name,
],
'comments_count' => $this->comments_count,
'status' => $this->isPublished() ? 'published' : 'draft',
'published_at' => optional($this->published_at)?->toIso8601String(),
];
}
}
9.2 コントローラ側
public function index()
{
$posts = Post::with('user')
->withCount('comments')
->published()
->latest()
->paginate(20);
return PostResource::collection($posts);
}
こうすると、モデルはデータと小さなルールに集中し、APIの見せ方は Resource 側で完結します。
10. Service / Action:複数モデルをまたぐ処理は外に出す
「投稿を保存したらタグも更新し、通知も送り、監査ログも残す」のような処理をモデルに書き始めると、すぐに Fat Model になります。こういう処理は Service や Action に出した方が分かりやすいです。
10.1 例:投稿作成Action
// app/Actions/CreatePostAction.php
class CreatePostAction
{
public function execute(User $user, array $data): Post
{
return DB::transaction(function () use ($user, $data) {
$post = $user->posts()->create([
'title' => $data['title'],
'body' => $data['body'],
'published_at' => $data['published_at'] ?? null,
]);
if (!empty($data['tag_ids'])) {
$post->tags()->sync($data['tag_ids']);
}
return $post;
});
}
}
10.2 コントローラ
public function store(StorePostRequest $request, CreatePostAction $action)
{
$post = $action->execute($request->user(), $request->validated());
return redirect()->route('posts.show', $post)
->with('status', '投稿を作成しました。');
}
モデルに全部を押し込まず、「保存という業務処理」として切り出すとテストもしやすくなります。
11. 集約と集計:画面のためのSQLをモデルに背負わせすぎない
ダッシュボードや一覧では集計が必要です。ここでモデルに巨大な集計メソッドを置き始めると、責務が曖昧になります。
11.1 軽い集計ならクエリで十分
$total = Order::whereDate('created_at', today())->sum('total_amount');
11.2 複雑ならQuery Serviceに分ける
// app/Services/DashboardStatsService.php
class DashboardStatsService
{
public function todayStats(): array
{
return [
'orders_count' => Order::whereDate('created_at', today())->count(),
'sales_total' => Order::whereDate('created_at', today())->sum('total_amount'),
];
}
}
モデルは「1件の性質」や「再利用条件」に寄せて、集計は別のクラスにすると見通しが良くなります。
12. 一覧画面を速く、分かりやすくするEloquentの書き方
一覧画面では、表示しやすさと性能が両方大事です。
12.1 例:管理画面の投稿一覧
$posts = Post::query()
->select(['id', 'user_id', 'title', 'published_at', 'created_at'])
->with(['user:id,name'])
->withCount('comments')
->keyword($request->string('q')->toString())
->latest()
->paginate(20)
->withQueryString();
12.2 UIへのつなぎ方
- 件数は数字で明示する
- 公開状態は色だけでなく、文字でも示す
- 並び順や絞り込み条件は画面に見えるようにする
- ページングは
withQueryString()で条件を保持する
Eloquent側で「一覧に必要な最小データ」を整えておくと、Bladeやフロント側の条件分岐が減り、アクセシビリティも整えやすくなります。
13. 更新処理:update() だけで終わらせない方がよい場面
単純な更新なら update() で十分です。
$post->update($request->validated());
ただし、次のような場合は注意です。
- 更新前後で監査ログを残したい
- 関連テーブルも更新する
- 条件付きで通知やイベント発火が必要
- 整合性を保つためにトランザクションが必要
この場合は Action に寄せた方が安全です。
14. テストしやすいEloquent設計:Factoryと前提条件を小さく保つ
Eloquent設計が良いと、テストも書きやすくなります。
14.1 スコープのテスト例
public function test_published_scope_returns_only_published_posts()
{
Post::factory()->create(['published_at' => now()->subDay()]);
Post::factory()->create(['published_at' => null]);
$posts = Post::published()->get();
$this->assertCount(1, $posts);
}
14.2 Actionのテスト例
public function test_create_post_action_creates_post_and_syncs_tags()
{
$user = User::factory()->create();
$tags = Tag::factory()->count(2)->create();
$post = app(CreatePostAction::class)->execute($user, [
'title' => '新規投稿',
'body' => '本文',
'tag_ids' => $tags->pluck('id')->all(),
]);
$this->assertDatabaseHas('posts', ['id' => $post->id, 'title' => '新規投稿']);
$this->assertCount(2, $post->tags);
}
責務が分かれていると、どこをテストすればよいかが明確になります。
15. よくある落とし穴と回避策
- モデルに外部APIやメール送信まで書いてしまう
- 回避:Action / Service に分離する
- 一覧で毎回 N+1 を再発する
- 回避:一覧は
withを前提に書く習慣を持つ
- 回避:一覧は
- スコープが増えすぎて何をしているか分からない
- 回避:再利用される条件だけを短く切り出す
- アクセサでHTMLや長い文言を返す
- 回避:表示整形は Resource / ViewModel に寄せる
- API用の配列整形がモデルに散る
- 回避:API Resource に統一する
fillableを曖昧にして大量代入リスクが残る- 回避:明示的に管理する
16. チェックリスト(配布用)
モデル
- [ ] リレーション名が意味に合っている
- [ ]
fillable/hidden/castsが整理されている - [ ] 状態判定メソッドは小さく分かりやすい
性能
- [ ] 一覧は
with/withCount/selectを意識している - [ ] N+1が起きやすい画面を把握している
- [ ] ページングと
withQueryString()を使っている
責務分離
- [ ] 再利用条件はスコープにまとまっている
- [ ] 大きな保存処理は Action / Service に分離している
- [ ] API整形は Resource に寄せている
- [ ] 集計は Query Service や専用クラスに逃がしている
テスト
- [ ] スコープの単体テストがある
- [ ] Action のテストがある
- [ ] 一覧やAPIの件数・構造がFeatureテストで守られている
アクセシビリティ
- [ ] 一覧の件数・状態が文字で分かる
- [ ] 色だけに依存しない状態表現になっている
- [ ] 画面側で読みやすい粒度のデータが渡されている
17. まとめ
Eloquentは、Laravelらしい生産性の中心です。だからこそ、何でもモデルに書くのではなく、モデルに置くものと置かないものを決めて使うと、長く保守しやすいコードになります。リレーション、スコープ、キャスト、状態判定まではモデルに。大きな保存処理や集計、API整形、外部連携は Action / Service / Resource へ。さらに一覧では with と withCount を習慣にし、N+1を最初から避けるだけで、性能と可読性が大きく改善します。Eloquentは「薄く使う」のではなく、「正しい責務で使う」と強いです。今日のプロジェクトでも、まずは1つの一覧と1つの保存処理から整理してみてください。

