Eloquentとは、Laravelに標準で入っているORM(データベースの1行をPHPのオブジェクトとして扱う仕組み)です。 1つのテーブルに1つのモデルクラスを対応させ、User::find(1)->posts のように、SQLを書かずに取得・保存・リレーションの読み込みができます。

この記事では Laravel 12 を前提に、モデル定義・CRUD・よく使うメソッドの一覧から、6種類のリレーションの書き方までを整理します。上位の解説記事にはない点として、各操作で Eloquent が発行する SQL を併記し、同じ SQL を MySQL 8.0 で実行した結果 を載せました。N+1 問題も、同じ形の SQL を PDO で発行してクエリ本数と時間を比べています。

Eloquentの使い方 早見表

やりたいこと Eloquent 発行される SQL(MySQL)
主キーで1件取得 User::find(1) select * from users where users.id = ? limit 1
見つからなければ404 User::findOrFail(1) 同上(0件なら ModelNotFoundException)
条件で取得 Post::where('published', true)->get() select * from posts where published = ?
1件目だけ Post::where(...)->first() ... limit 1
件数 Post::count() select count(*) as aggregate from posts
作成 Post::create([...]) insert into posts (...) values (...)
更新 $post->update([...]) update posts set ... where id = ?
削除 $post->delete() delete from posts where id = ?(論理削除なら update)
あれば取得・なければ作成 User::firstOrCreate([...], [...]) select → 必要なら insert
あれば更新・なければ作成 User::updateOrCreate([...], [...]) select → update か insert
一括 UPSERT Post::upsert($rows, uniqueBy: [...], update: [...]) insert ... on duplicate key update
子を取得(1対多) $user->posts select * from posts where posts.user_id = ? and posts.user_id is not null
まとめて先読み User::with('posts')->get() select * from posts where posts.user_id in (1, 2, 3)
件数を付ける User::withCount('posts')->get() 副問い合わせ (select count(*) ...) as posts_count
関連の条件で絞る User::whereHas('posts', fn ($q) => ...) where exists (select * from posts ...)
発行 SQL を確認 ->toRawSql() / DB::listen() 値を埋め込んだ SQL を表示

ポイントは「プロパティ($user->posts)はその場で SQL を発行し、メソッド($user->posts())はクエリを組み立てるだけ」という違いです。 この違いが N+1 問題の出発点になります。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Laravel Eloquentの使い方|リレーション・N+1対策」

検証方法と対象バージョン

項目 内容
Laravel 12.x(コードは公式ドキュメント laravel.com/docs/12.x で確認)
PHP 8.4.21(PDO で SQL を発行)
データベース MySQL 8.0.46
SQL の検証 Eloquent が発行するのと同じ SQL を MySQL 8.0 で実行した結果 を掲載

この記事の検証環境では Composer で Laravel をインストールできなかったため、Laravel 本体のコードは実行していません。PHP コードは公式ドキュメントに沿って記載し、各操作の SQL は、Eloquent が組み立てる形(バッククォートやテーブル名修飾を含む)をそのまま MySQL に流して結果を確認しました。お手元の環境では toRawSql() で同じ SQL になることを確認できます。

MySQL 8.0 は2026年4月にサポートが終了しています。 この記事の SQL は MySQL 8.4 LTS・9.7 LTS でも同じ手順で使えます(今回扱う構文に違いはありません)。

モデルを作る

php artisan make:model Post -m      # モデルとマイグレーションを同時に作成
php artisan make:model Tag --pivot  # 中間テーブル用のモデル
<?php
// app/Models/Post.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;

class Post extends Model
{
    use SoftDeletes;

    // 一括代入を許可する列
    protected $fillable = ['user_id', 'title', 'published'];

    protected function casts(): array
    {
        return [
            'published' => 'boolean',
        ];
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function comments(): HasMany
    {
        return $this->hasMany(Comment::class);
    }

    public function tags(): BelongsToMany
    {
        return $this->belongsToMany(Tag::class);
    }

    public function images(): MorphMany
    {
        return $this->morphMany(Image::class, 'imageable');
    }
}

Eloquent は名前の規約で設定を省略できます。規約から外れるときだけプロパティで上書きします。

項目 規約 上書き方法
テーブル名 クラス名の複数形スネークケース(Post → posts) protected $table = 'my_posts';
主キー id(自動採番の整数) $primaryKey、$incrementing、$keyType
タイムスタンプ created_at・updated_at を自動更新 public $timestamps = false;
外部キー 親モデル名のスネークケース+_id(user_id) リレーションの第2引数
中間テーブル 2モデル名の単数形をアルファベット順に _ で連結(post_tag) belongsToMany の第2引数

検証用には次のテーブルを MySQL 8.0 に作りました(Laravel のマイグレーションで作る構造と同じ列構成です)。

CREATE TABLE users (id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255),
  email VARCHAR(255) UNIQUE, created_at TIMESTAMP NULL, updated_at TIMESTAMP NULL);
CREATE TABLE profiles (id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id BIGINT UNSIGNED, bio VARCHAR(255));
CREATE TABLE posts (id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id BIGINT UNSIGNED,
  title VARCHAR(255), published TINYINT(1) DEFAULT 0,
  created_at TIMESTAMP NULL, updated_at TIMESTAMP NULL, deleted_at TIMESTAMP NULL);
CREATE TABLE comments (id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  post_id BIGINT UNSIGNED, body VARCHAR(255));
CREATE TABLE tags (id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50));
CREATE TABLE post_tag (post_id BIGINT UNSIGNED, tag_id BIGINT UNSIGNED,
  PRIMARY KEY (post_id, tag_id));
CREATE TABLE images (id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, url VARCHAR(255),
  imageable_type VARCHAR(255), imageable_id BIGINT UNSIGNED);

CRUDの基本

use App\Models\Post;

// 作成($fillable にある列だけ代入される)
$post = Post::create(['user_id' => 1, 'title' => 'Eloquent basics', 'published' => true]);

// 取得
$post  = Post::find(1);
$posts = Post::where('published', true)->orderByDesc('created_at')->get();

// 更新
$post->title = 'Eloquent basics (2nd)';
$post->save();
// または
$post->update(['title' => 'Eloquent basics (2nd)']);

// 削除
$post->delete();
Post::destroy([2, 3]);

save() は変更された列だけを UPDATE します。 何も変えずに save() しても UPDATE は発行されません。変更の有無は $post->isDirty('title')、保存後に変わった列は $post->wasChanged() で確認できます。

よく使うメソッド一覧

分類 メソッド 戻り値・ポイント
取得 all() / get() Collection(0件なら空の Collection)
取得 find($id) / first() モデル または null
取得 findOrFail() / firstOrFail() 0件で ModelNotFoundException(HTTP では 404)
取得 value('title') / pluck('title', 'id') 1列の値/キー付き Collection
存在確認 exists() / doesntExist() bool
集計 count() / sum() / max() / avg() 数値
大量データ chunk(200, fn) / chunkById() / lazy() / cursor() メモリを抑えて処理
作成 create() / firstOrCreate() / updateOrCreate() モデル
一括 insert() / upsert() イベント・タイムスタンプは自動では付かない(upsert は付く)
更新 update() / increment('views') 件数またはbool
削除 delete() / destroy() / forceDelete() 論理削除・物理削除
変換 toArray() / toJson() $hidden・$appends が反映される

chunk() で処理中の行を更新すると、ページがずれて取りこぼすことがあります。 更新しながら回すときは chunkById() か lazyById() を使います。

クエリビルダとの違い

use Illuminate\Support\Facades\DB;

$a = DB::table('posts')->where('published', 1)->get();   // stdClass の Collection
$b = Post::where('published', true)->get();              // Post モデルの Collection
観点 Eloquent クエリビルダ(DB::table)
戻り値 モデル(リレーション・アクセサ付き) stdClass
論理削除 deleted_at is null を自動付与 付かない
タイムスタンプ 自動で入る 自分で入れる
イベント・オブザーバー 発火する 発火しない
キャスト・アクセサ 効く 効かない
向いている処理 画面・APIの通常の読み書き 集計、大量の一括更新、複雑なレポート

どちらも内部では同じクエリビルダを使うので、where や join の書き方は共通です。 1万行を超える一括更新や集計だけのクエリはクエリビルダ、それ以外は Eloquent、と使い分けると読みやすさと速度を両立できます。

リレーションの定義と発行SQL

以下の「実行結果」は、Eloquent が発行するのと同じ SQL を MySQL 8.0.46 で実行した結果 です。

リレーション 関係の例 定義するメソッド 逆側
1対1 User → Profile hasOne(Profile::class) belongsTo(User::class)
1対多 User → Post hasMany(Post::class) belongsTo(User::class)
多対多 Post ⇔ Tag belongsToMany(Tag::class) belongsToMany(Post::class)
中間経由 User → Post → Comment hasManyThrough(Comment::class, Post::class) -
ポリモーフィック Post・User → Image morphMany(Image::class, 'imageable') morphTo()

hasOne(1対1)

// User.php
public function profile(): HasOne
{
    return $this->hasOne(Profile::class);
}

$bio = User::find(1)->profile->bio;
select * from `profiles` where `profiles`.`user_id` = 1 and `profiles`.`user_id` is not null limit 1;
+----+---------+---------+
| id | user_id | bio     |
+----+---------+---------+
|  1 |       1 | backend |
+----+---------+---------+

hasMany(1対多)

// User.php
public function posts(): HasMany
{
    return $this->hasMany(Post::class);
}

$posts = User::find(1)->posts;                                   // 全件
$draft = User::find(1)->posts()->where('published', false)->get(); // 条件を追加

Post モデルは SoftDeletes を使っているため、deleted_at is null が自動で付きます。

select * from `posts` where `posts`.`user_id` = 1 and `posts`.`user_id` is not null and `posts`.`deleted_at` is null;
+----+---------+-----------------+-----------+---------------------+---------------------+------------+
| id | user_id | title           | published | created_at          | updated_at          | deleted_at |
+----+---------+-----------------+-----------+---------------------+---------------------+------------+
|  1 |       1 | Eloquent basics |         1 | 2026-10-02 01:06:39 | 2026-10-02 01:06:39 | NULL       |
|  2 |       1 | Migration steps |         0 | 2026-10-02 01:06:39 | 2026-10-02 01:06:39 | NULL       |
+----+---------+-----------------+-----------+---------------------+---------------------+------------+

belongsTo(子から親)

// Post.php
public function user(): BelongsTo
{
    return $this->belongsTo(User::class)->withDefault(['name' => 'Guest']);
}

$name = Post::find(3)->user->name;
select * from `users` where `users`.`id` = 2 limit 1;
+----+------+------------------+---------------------+---------------------+
| id | name | email            | created_at          | updated_at          |
+----+------+------------------+---------------------+---------------------+
|  2 | Baba | baba@example.com | 2026-10-02 01:06:39 | 2026-10-02 01:06:39 |
+----+------+------------------+---------------------+---------------------+

withDefault() を付けると、親がない(user_id が null)ときも空のモデルが返り、->name で null エラーになりません。

belongsToMany(多対多)

// Post.php
public function tags(): BelongsToMany
{
    return $this->belongsToMany(Tag::class)->withTimestamps(); // 中間テーブルに created_at 等がある場合
}

foreach (Post::find(1)->tags as $tag) {
    echo $tag->name, ' ', $tag->pivot->tag_id, PHP_EOL;
}

// 中間テーブルの操作
$post->tags()->attach([1, 2]);   // 追加
$post->tags()->detach(3);        // 削除
$post->tags()->sync([1, 2]);     // この配列と同じ状態にそろえる
select `tags`.*, `post_tag`.`post_id` as `pivot_post_id`, `post_tag`.`tag_id` as `pivot_tag_id`
from `tags` inner join `post_tag` on `tags`.`id` = `post_tag`.`tag_id`
where `post_tag`.`post_id` = 1;
+----+---------+---------------+--------------+
| id | name    | pivot_post_id | pivot_tag_id |
+----+---------+---------------+--------------+
|  1 | php     |             1 |            1 |
|  2 | laravel |             1 |            2 |
|  3 | db      |             1 |            3 |
+----+---------+---------------+--------------+

pivot_ で始まる列が $tag->pivot にまとめられます。 中間テーブルの他の列も使うときは withPivot('列名') を追加します。

hasManyThrough(中間テーブル経由)

// User.php:ユーザーの投稿に付いたコメントを一度に取得
public function postComments(): HasManyThrough
{
    return $this->hasManyThrough(Comment::class, Post::class);
}
select `comments`.*, `posts`.`user_id` as `laravel_through_key`
from `comments` inner join `posts` on `posts`.`id` = `comments`.`post_id`
where `posts`.`deleted_at` is null and `posts`.`user_id` = 1;
+----+---------+-------------+---------------------+
| id | post_id | body        | laravel_through_key |
+----+---------+-------------+---------------------+
|  1 |       1 | Helpful     |                   1 |
|  2 |       1 | More please |                   1 |
+----+---------+-------------+---------------------+

中間モデル(Post)が論理削除を使っていると、中間側の deleted_at is null も付きます。

morphMany / morphTo(ポリモーフィック)

1つの images テーブルを、投稿とユーザーの両方から使う例です。imageable_type に親のクラス名、imageable_id に親の ID を入れます。

// Post.php / User.php
public function images(): MorphMany
{
    return $this->morphMany(Image::class, 'imageable');
}

// Image.php
public function imageable(): MorphTo
{
    return $this->morphTo();
}
select * from `images` where `images`.`imageable_type` = 'App\\Models\\Post'
  and `images`.`imageable_id` = 1 and `images`.`imageable_id` is not null;
+----+-------------+-----------------+--------------+
| id | url         | imageable_type  | imageable_id |
+----+-------------+-----------------+--------------+
|  2 | /img/p1.png | App\Models\Post |            1 |
+----+-------------+-----------------+--------------+

imageable_type にクラス名をそのまま保存すると、名前空間を変えたときにデータの更新が必要になります。 AppServiceProvider の boot() で Relation::enforceMorphMap(['post' => Post::class, 'user' => User::class]); を設定すると、'post' のような短い別名で保存されます。

N+1問題の再現と解決

N+1 問題とは、親を1回取得したあと、親の件数(N)だけ子を取得する SQL が発行される状態です。 一般的な原因と各フレームワークでの見つけ方は別記事「N+1問題とは?原因と見つけ方・解決方法」で扱っているので、ここでは Eloquent の書き方に絞ります。

// 直す前:User 500件 → posts の SQL が 500 回
$users = User::all();
foreach ($users as $user) {
    echo $user->posts->count();
}

// 直した後:SQL は 2 回
$users = User::with('posts')->get();
foreach ($users as $user) {
    echo $user->posts->count();
}

ユーザー500人・投稿1,500件のテーブルで、上のコードと同じ形の SQL を PHP 8.4 の PDO から MySQL 8.0 に発行して比べました(同一サーバー上のソケット接続、3回実行)。

lazy (N+1)                   queries= 501  posts=1500  49.0 ms
eager with()                 queries=   2  posts=1500  1.6 ms
lazy (N+1)                   queries= 501  posts=1500  47.4 ms
eager with()                 queries=   2  posts=1500  1.6 ms
lazy (N+1)                   queries= 501  posts=1500  42.3 ms
eager with()                 queries=   2  posts=1500  1.7 ms

取得結果は同じ1,500件のまま、SQL は501回から2回に減りました。 アプリとDBが別サーバーの場合は1回ごとに通信の往復が加わるため、差はさらに大きくなります。with() が発行する2本目の SQL は次の形です。

select * from `posts` where `posts`.`user_id` in (1, 2, 3) and `posts`.`deleted_at` is null;

with・load・withCount・whereHas の使い分け

書き方 いつ使う 発行される SQL
User::with('posts')->get() 取得と同時に子も使うとわかっている 親1本+子を in (...) で1本
User::with('posts.comments') 孫まで使う 階層ごとに1本ずつ
User::with('posts:id,user_id,title') 子の列を絞る(外部キーは必須) select id, user_id, title ...
$users->load('posts') 取得した後で、条件によって読み込む 子を in (...) で1本
$user->loadMissing('posts') 未読み込みのときだけ読む 必要なときだけ1本
User::withCount('posts') 件数だけ欲しい 副問い合わせで posts_count
User::whereHas('posts', fn) 子の条件で親を絞る where exists (...)
User::withWhereHas('posts', fn) 絞り込みと読み込みを同じ条件で exists + in (...)

withCount が発行する SQL と結果:

select `users`.*, (select count(*) from `posts` where `users`.`id` = `posts`.`user_id`
  and `posts`.`deleted_at` is null) as `posts_count` from `users`;
+------+-------+-------------------+---------------------+---------------------+-------------+
| id   | name  | email             | created_at          | updated_at          | posts_count |
+------+-------+-------------------+---------------------+---------------------+-------------+
|    1 | Aoki  | aoki@example.com  | 2026-10-02 01:06:39 | 2026-10-02 01:06:39 |           2 |
|    2 | Baba  | baba@example.com  | 2026-10-02 01:06:39 | 2026-10-02 01:06:39 |           1 |
|    3 | Chiba | chiba@example.com | 2026-10-02 01:06:39 | 2026-10-02 01:06:39 |           1 |
+------+-------+-------------------+---------------------+---------------------+-------------+

件数だけ表示するなら with('posts') で全件を読むより withCount の方が転送量が少なく済みます。 whereHas は次のように exists 副問い合わせになります。

$users = User::whereHas('posts', fn ($q) => $q->where('published', true))->get();
select * from `users` where exists (select * from `posts` where `users`.`id` = `posts`.`user_id`
  and `published` = 1 and `posts`.`deleted_at` is null);

preventLazyLoading で N+1 を開発中に見つける

// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    // 本番以外では遅延読み込みを例外にする
    Model::preventLazyLoading(! $this->app->isProduction());
}

この設定で with() を付けずに $user->posts を読むと、Illuminate\Database\LazyLoadingViolationException が投げられ、どのモデルのどのリレーションで起きたかがメッセージに表示されます(公式ドキュメントで確認、未実行)。本番ではログに残すだけにしたい場合は Model::handleLazyLoadingViolationsUsing() でコールバックを登録します。

開発中は「Model::shouldBeStrict(! $this->app->isProduction())」でまとめて有効にする方法もあります。 これは遅延読み込みの防止に加え、$fillable にない属性の黙った破棄(preventSilentlyDiscardingAttributes)や、取得していない列へのアクセス(preventAccessingMissingAttributes)も例外にします。

なお Laravel 12 には、使われたリレーションを自動でまとめて読み込む Model::automaticallyEagerLoadRelationships() も追加されていますが、公式ドキュメントでは beta と記載されています。

スコープ・アクセサ・キャスト・論理削除

ローカルスコープ(よく使う条件に名前を付ける)

Laravel 12 では #[Scope] 属性で定義できます。従来の scopeXxx という名前のメソッドも引き続き使えます。

use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;

class Post extends Model
{
    #[Scope]
    protected function published(Builder $query): void
    {
        $query->where('published', true);
    }

    #[Scope]
    protected function ofUser(Builder $query, int $userId): void
    {
        $query->where('user_id', $userId);
    }
}

$posts = Post::published()->ofUser(1)->get();
// select * from posts where published = ? and user_id = ? and posts.deleted_at is null

全クエリに自動で条件を付けたいときは、#[ScopedBy([ActiveScope::class])] でグローバルスコープを登録します。

アクセサ・ミューテタ(Attribute)

use Illuminate\Database\Eloquent\Casts\Attribute;

class User extends Model
{
    protected function name(): Attribute
    {
        return Attribute::make(
            get: fn (string $value) => ucfirst($value),   // 取り出すとき
            set: fn (string $value) => trim($value),      // 保存するとき
        );
    }

    // DBにない値を JSON に含めたいとき
    protected $appends = ['display_name'];

    protected function displayName(): Attribute
    {
        return Attribute::make(get: fn () => $this->name . ' さん');
    }
}

メソッド名はキャメルケース(displayName)、属性名はスネークケース(display_name)で対応します。

キャスト

protected function casts(): array
{
    return [
        'published'    => 'boolean',
        'published_at' => 'immutable_datetime',
        'price'        => 'decimal:2',
        'options'      => 'array',            // JSON 列 ⇔ 配列
        'status'       => PostStatus::class,  // PHP の enum
        'password'     => 'hashed',           // 保存時にハッシュ化
    ];
}
キャスト DB の値 PHP での値
boolean 0 / 1 false / true
datetime / immutable_datetime 2026-10-02 10:00:00 Carbon / CarbonImmutable
decimal:2 1200.5 "1200.50"(文字列)
array / AsCollection::class JSON 文字列 配列 / Collection
enum クラス 'draft' PostStatus::Draft
encrypted 暗号文 平文

論理削除(SoftDeletes)

use SoftDeletes; と deleted_at 列があると、delete() は行を消さずに日時を入れる UPDATE になります。

$post->delete();                         // 論理削除
Post::withTrashed()->get();              // 削除済みも含める
Post::onlyTrashed()->get();              // 削除済みだけ
$post->restore();                        // 元に戻す
$post->forceDelete();                    // 物理削除

Eloquent が発行するのと同じ SQL を実行した結果:

update `posts` set `deleted_at` = '2026-10-02 10:00:00', `posts`.`updated_at` = '2026-10-02 10:00:00' where `id` = 2;
select id, title, deleted_at from `posts` where `posts`.`deleted_at` is null;      -- 通常の取得
select id, title, deleted_at from `posts` where `posts`.`deleted_at` is not null;  -- onlyTrashed()
+----+-----------------+------------+
| id | title           | deleted_at |
+----+-----------------+------------+
|  1 | Eloquent basics | NULL       |
|  3 | Blade tips      | NULL       |
|  4 | Queue setup     | NULL       |
+----+-----------------+------------+
+----+-----------------+---------------------+
| id | title           | deleted_at          |
+----+-----------------+---------------------+
|  2 | Migration steps | 2026-10-02 10:00:00 |
+----+-----------------+---------------------+

論理削除した行もユニークキーの対象に残ります。 同じメールアドレスで再登録できるようにしたい場合は、UNIQUE 制約に deleted_at を含めるなどの設計が必要です。また DB::table('posts') のクエリビルダでは deleted_at is null が付かないので、論理削除済みの行も返ります。

一括代入($fillable)の注意

// リクエストの値をそのまま渡すと、$fillable にない列は無視される
Post::create($request->all());

// 検証済みの値だけを渡す
Post::create($request->validated());
設定 動き 向いている場面
$fillable = ['title', ...] 列挙した列だけ代入できる 通常はこれ(許可リスト)
$guarded = ['id', 'is_admin'] 列挙した列以外は代入できる 列が多く、除外が少ない
$guarded = [] すべて代入できる 入力を必ず検証してから渡す場合のみ

$fillable にない列は、既定ではエラーにならずに黙って捨てられます。 「保存したはずの列が空のまま」というときは、$fillable を確認します。Model::preventSilentlyDiscardingAttributes() を有効にすると、捨てずに例外にできます。

よくあるエラーと直し方

以下のメッセージは公式ドキュメントと Laravel のソースに基づく形式で、この記事の環境では Laravel を実行していません(未実行)。

例外 メッセージの形 原因 直し方
MassAssignmentException Add [title] to fillable property to allow mass assignment on [App\Models\Post]. $fillable が空(または $guarded = ['*'])のモデルに create() $fillable に列を追加
ModelNotFoundException No query results for model [App\Models\Post] 999 findOrFail で0件 存在確認、find + null 判定
RelationNotFoundException Call to undefined relationship [post] on model [App\Models\User]. with() の名前とメソッド名の不一致 メソッド名(posts)と合わせる
LazyLoadingViolationException Attempted to lazy load [posts] on model [App\Models\User] but lazy loading is disabled. preventLazyLoading 中の遅延読み込み with() / load() を追加
MissingAttributeException The attribute [email] either does not exist or was not retrieved for model [App\Models\User]. select() で取得していない列を参照 列を select に含める
QueryException(SQLSTATE[23000]) Integrity constraint violation: 1062 Duplicate entry ... DB の UNIQUE・外部キー違反 updateOrCreate・upsert、事前チェック
QueryException(SQLSTATE[42S22]) Column not found: 1054 Unknown column ... 規約と違う外部キー名 リレーションの第2引数で外部キーを指定

QueryException の中身は MySQL のエラーそのものです。 1062・1452・1054 などの番号から原因を引くときは、別記事「MySQLエラーコード一覧と対処法」の表が使えます。

with('posts:id,title') のように列を絞るときに外部キー(user_id)を入れ忘れると、エラーにはならずにリレーションが空になります。列を絞るときは主キーと外部キーを必ず含めます。

よくある質問

Q. $user->posts と $user->posts() は何が違いますか? A. $user->posts はその場で SQL を発行して Collection を返し、同じモデルでは2回目以降キャッシュされた結果を使います。$user->posts() はリレーションのクエリを返すだけなので、->where() を続けて条件を足せます。

Q. with() と load() はどちらを使えばよいですか? A. 取得時点で子を使うとわかっていれば with()、取得後に条件によって読み込みたいときは load() です。発行される子の SQL は同じ in (...) の形です。

Q. 発行された SQL を確認する方法は? A. クエリの組み立て中なら ->toRawSql() や ->dd()、実行された SQL をまとめて見るなら DB::listen() や DB::enableQueryLog() を使います。開発用パッケージ(Laravel Debugbar・Telescope など)でも確認できます。

Q. MySQL 8.4 や 9.7 でも同じように動きますか? A. この記事で扱った SQL は MySQL 8.4・9.7 でも同じ構文で動きます。接続ユーザーが caching_sha2_password を使っていれば、Laravel 側の設定変更も不要です。

まとめ

  • Eloquent は1テーブル1モデル。規約(テーブル名・外部キー)に合わせると設定を省ける
  • プロパティ($user->posts)は即 SQL、メソッド(posts())はクエリを組み立てる
  • リレーションごとに発行 SQL の形が決まっている。toRawSql() で確かめる習慣をつける
  • N+1 は with() で 501 本 → 2 本。件数だけなら withCount、絞り込みは whereHas
  • 開発中は preventLazyLoading(または shouldBeStrict)で遅延読み込みを例外に
  • $fillable にない列は黙って捨てられる。検証済みの値だけを渡す
  • 論理削除はクエリビルダでは効かない。ユニーク制約の設計もあわせて考える

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Laravel Eloquentの使い方|リレーション・N+1対策」

株式会社RJCでは、Laravel Eloquentの設計と使い方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?