php elephant sticker
Photo by RealToughCandy.com on Pexels.com
目次

【実務完全ガイド】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 へ。さらに一覧では withwithCount を習慣にし、N+1を最初から避けるだけで、性能と可読性が大きく改善します。Eloquentは「薄く使う」のではなく、「正しい責務で使う」と強いです。今日のプロジェクトでも、まずは1つの一覧と1つの保存処理から整理してみてください。


参考リンク

投稿者 greeden

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)