- 技術 💻
Laravel Eloquentの使い方|リレーション・N+1対策
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期連続の黒字決算。