Laravel『Unexpected Eloquent Behavior』の原因と対処法

Laravel『Unexpected Eloquent Behavior』の原因と対処法

「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…))を確認したか