Laravel『Unexpected Eloquent Behavior』の原因と対処法
- 作成日 2025.12.25
- その他
「Unexpected Eloquent Behavior」はLaravel標準の固定エラーメッセージというより、「Eloquentが想定外の動きをした」と感じるときに出がちな現象の総称として扱われることが多い。実態は、グローバルスコープやSoftDeletes、リレーションの遅延/先読み、属性キャスト、mass assignment、イベント/オブザーバ、トランザクション、クエリキャッシュ、N+1、同名カラムの上書きなどが絡み、取得結果や更新結果が“意図と違う”形になるケースが多い。まず「いつ」「どのクエリ」「どのモデル状態」で期待とズレたのかを固定し、Eloquentの暗黙動作を1つずつ無効化/明示化して原因を潰すのが最短ルート。
発生条件(典型パターン)
「想定外」の出方は幅広いが、よくある発生条件は次の通り。
・DBにはレコードがあるのに find()/get() で取れない(SoftDeletes/スコープ)
・更新したのに値が保存されない、別の値になる(fillable/guarded、cast、mutator)
・リレーションがnull/空、または過剰にクエリが走る(N+1、with不足、名前衝突)
・where条件が効いていないように見える(orWhereの括り、ローカルスコープの副作用)
・同じコードでも環境で結果が違う(DB設定差、タイムゾーン、コレーション)
最初にやること:実行SQLとモデル状態を固定する
Eloquentの挙動を追うには、実行されたSQLとbindings、モデルの属性(dirty/changes)を見える化すると速い。
use Illuminate\Support\Facades\DB;
DB::listen(function ($q) {
logger()->debug('sql', [
'sql' => $q->sql,
'bindings' => $q->bindings,
'time_ms' => $q->time,
]);
});モデル保存の「何が変更扱いになっているか」を確認する例。
$user = User::find(1);
$user->name = 'Taro';
logger()->debug('dirty', ['dirty' => $user->getDirty()]);
$user->save();
logger()->debug('changes', ['changes' => $user->getChanges()]);
この2つ(SQL・dirty/changes)で、だいたい原因が絞れる。
原因1:SoftDeletes・グローバルスコープで「取れない」
DBにはあるのに取得できない代表格。
発生条件:
・SoftDeletesで deleted_at が入っている
・グローバルスコープ(is_active=1、company_id固定)がある
対処:必要箇所だけ明示的に外す/含める。
// SoftDeletes
$post = Post::withTrashed()->find($id);
// グローバルスコープ(全外し)
$post = Post::withoutGlobalScopes()->find($id);「管理画面だけ見える」「一般画面では見せない」という設計なら、404になっても仕様どおりの可能性があるため、スコープの意図を確認する。
原因2:Mass Assignment(fillable/guarded)で保存されていない
create()/update()/fill() で値を入れたつもりが、fillableに無いカラムは黙って無視される。
// NG: fillable未設定だと反映されないことがある
User::create([
'name' => 'Taro',
'is_admin' => 1,
]);対処:fillableを適切に設定、またはガード方針を決める。
// app/Models/User.php
protected $fillable = ['name', 'email']; // 必要なものだけ許可一時的に全部許可(運用では慎重に)。
protected $guarded = [];「更新できない」系のUnexpected Behaviorはこれが多い。
原因3:cast / accessor / mutator で値が変換されている
取得した値が勝手に型変換される、保存時に加工される、という現象。
発生条件:
・casts に boolean / array / json / datetime があり、想定と型が違う
・setXxxAttribute で保存時に変換している
例:booleanキャストで “0”/”1″ が true/false になる、jsonが配列になる、など。
// app/Models/User.php
protected $casts = [
'is_active' => 'boolean',
'meta' => 'array',
'email_verified_at' => 'datetime',
];対処:
・castsの型を意図に合わせる
・変換前の生値が必要なら getRawOriginal() を使う
$raw = $user->getRawOriginal('meta');原因4:リレーションの読み込み不足/誤り(N+1・null・別条件)
「リレーションが空」「パフォーマンスが急に悪化」「withしてるのに増える」など。
発生条件:
・ループ内で $user->posts を呼んでN+1
・外部キー/ローカルキーの指定ミスで別データを引いている
・selectで必要カラムを落としていて、リレーションが解決できない
対処:
・必要なリレーションは eager load(with)
$users = User::query()->with('posts')->paginate(20);
・select最適化するなら、キーも必ず含める
$users = User::query()
->select(['id', 'name']) // idが無いとリレーションに支障
->with('posts')
->get();・belongsTo/hasMany のキー指定を確認
public function company()
{
return $this->belongsTo(Company::class, 'company_id', 'id');
}原因5:where/orWhere の括りが崩れて条件が意図と違う
Eloquentの“動き”というより、クエリの論理が想定と違って結果が変になる代表。
// NG: orWhereが全体に効いてしまう例
User::where('company_id', 1)
->where('is_active', 1)
->orWhere('role', 'admin')
->get();
これだと「company_id=1 AND is_active=1」または「role=admin」のユーザーが全部混ざる。
対処:クロージャで括る。
// OK
User::where('company_id', 1)
->where(function ($q) {
$q->where('is_active', 1)
->orWhere('role', 'admin');
})
->get();サンプル:挙動を安定させる“明示的Eloquent”テンプレ
スコープ、型、条件、リレーション、ソートを明示して、結果がブレないようにする例。
$users = User::query()
->withoutGlobalScopes() // 必要なときだけ(管理画面など)
->with(['company', 'posts']) // N+1回避
->when(request('q'), function ($q) {
$q->where('name', 'like', '%'.request('q').'%');
})
->orderByDesc('created_at')
->orderByDesc('id') // 安定ソート
->paginate(20)
->withQueryString();チェックリスト(上から順に確認する)
1) 実行SQLとbindingsをログで確認したか(DB::listen)
2) 「取れない」なら SoftDeletes / グローバルスコープ / テナント条件を疑ったか
3) 「保存されない」なら fillable/guarded と getDirty()/getChanges() を確認したか
4) 値が変なら casts / accessor / mutator / getRawOriginal() を確認したか
5) リレーションが変なら with() とキー指定、selectでキーを落としていないかを確認したか
6) 条件が変なら orWhere の括り(where(function…))を確認したか
-
前の記事
Laravel『UnauthorizedHttpException』の原因と対処法 2025.12.24
-
次の記事
Laravel『Route Method Not Allowed』の原因と対処法 2025.12.26
コメントを書く