Laravel『Model Not Found(ModelNotFoundException)』の原因と対処法

Laravel『Model Not Found(ModelNotFoundException)』の原因と対処法

Laravelの「Model Not Found」は、多くの場合 ModelNotFoundException(Illuminate\Database\Eloquent\ModelNotFoundException)として発生し、findOrFail / firstOrFail / route model binding など「見つからなかったら例外にする」系の処理で、対象レコードが存在しないときに投げられる。IDの不一致、ルートパラメータの取り違え、SoftDeletesで論理削除されている、グローバルスコープで検索対象から外れている、テナント条件(会社IDなど)で絞られている、接続先DBが違う、などが典型原因。例外そのものは仕様どおりでも、「404にしたいのか」「見つからない原因を潰したいのか」で対処が変わる。

エラーの出方と発生条件

典型メッセージ。

Illuminate\Database\Eloquent\ModelNotFoundException
No query results for model [App\Models\User] 123

発生条件の典型:
・findOrFail($id) / firstOrFail() を使っていて、対象が無い
・Route Model Binding(/users/{user})で、該当モデルが見つからない
・SoftDeletesで削除済みのレコードを通常検索している
・where条件(例:company_id)を付けた結果、ヒットしない
・環境差(DB接続先/シードデータ不足)でローカルだけ/本番だけ発生

原因1:IDや検索条件が間違っている(パラメータ取り違え)

URLやフォームから来た値が「本当にそのモデルの主キーか」を確認する。
よくある事故:
・/posts/{post} のつもりが user_id を渡している
・ルート定義の順番やパラメータ名がズレて別の値が入っている

// 例: posts.show なのに user_id を渡してしまう
return redirect()->route('posts.show', ['post' => $request->input('user_id')]);

対処:
・パラメータ名を明確にする(post_id, user_id)
・ルート生成時に渡すキーを確認する
・ログで受け取っているIDを確認する

logger()->info('post id', ['id' => $id]);
$post = Post::findOrFail($id);

原因2:Route Model Bindingで見つからない(ルートとモデルの紐づけ)

Route Model Binding を使うと、Laravelが自動でモデルを探して見つからなければ例外(結果的に404)になる。

// routes/web.php
Route::get('/users/{user}', [UserController::class, 'show']);

// Controller
public function show(\App\Models\User $user)
{
    return view('users.show', compact('user'));
}

発生条件:
・/users/9999 にアクセスしたが存在しない
・主キーではなく slug で探したいのにデフォルトのまま
対処:slug運用なら binding のキーを変える。

// app/Models/Post.php
public function getRouteKeyName()
{
    return 'slug';
}

これで /posts/{post} は slug で解決され、存在しないslugなら同様に見つからない扱いになる。

原因3:SoftDeletesで削除されている(論理削除が見えない)

SoftDeletesを使っているモデルは、通常クエリでは deleted_at がある行は検索対象外。

use Illuminate\Database\Eloquent\SoftDeletes;

class Post extends Model
{
    use SoftDeletes;
}

発生条件:
・実データはあるが deleted_at が入っているため findOrFail で落ちる
対処:用途に応じて withTrashed / onlyTrashed を使う。

// 削除済みも含めて探す
$post = Post::withTrashed()->findOrFail($id);

// 削除済みだけから探す
$post = Post::onlyTrashed()->findOrFail($id);

削除済みを見せたくないなら、例外=404は仕様として扱い、管理画面だけ withTrashed にする設計が多い。

原因4:グローバルスコープやテナント制約で弾かれている

global scope(例:is_active=1、company_id固定)を使っていると、存在してもスコープで除外されて見つからない。
発生条件:
・管理者は見えるべきなのに、スコープにより findOrFail が落ちる
対処:スコープを外して取得する(必要箇所だけ)。

// 例: 特定のグローバルスコープを外す(スコープ名は実装に依存)
$post = Post::withoutGlobalScope('active')->findOrFail($id);

// 全グローバルスコープを外す
$post = Post::withoutGlobalScopes()->findOrFail($id);

テナント制約(company_id)を手動で付けている場合も同様で、「そのIDがその会社に属しているか」がズレると必ず落ちる。

$post = Post::where('company_id', auth()->user()->company_id)->findOrFail($id);

この場合は「IDは存在するが権限的に見せない」=404として正しい設計になっていることも多い。

原因5:接続先DB・環境差(ローカル/本番でデータが無い)

ローカルはシード済みで通るが、本番・ステージングでは未投入で落ちるパターン。
確認ポイント:
・DB_DATABASE/DB_HOST/DB_USERNAME が想定どおりか
・migrateは当たっているか、対象テーブルにレコードがあるか
・キャッシュ(config:cache)で古い .env が使われていないか

php artisan config:clear
php artisan cache:clear
php artisan migrate:status

本番での確認は運用ポリシーに合わせる(不用意にクリアコマンドを常用しない)。

対処パターン別:404で返す/任意メッセージで返す/例外を握る

ModelNotFoundExceptionは「見つからない」なので、HTTP的には404が自然。
・通常のWeb画面:404でOK(Laravel標準)
・API:JSONで404を返したい
・業務要件:ユーザーに分かりやすい画面に戻したい
APIでJSONにしたい例(例外ハンドラ)。

// app/Exceptions/Handler.php
use Illuminate\Database\Eloquent\ModelNotFoundException;

public function render($request, Throwable $e)
{
    if ($e instanceof ModelNotFoundException && $request->expectsJson()) {
        return response()->json([
            'message' => 'Resource not found',
        ], 404);
    }

    return parent::render($request, $e);
}

「例外を投げずに分岐したい」なら find / first を使う。

$post = Post::find($id);

if (!$post) {
    return redirect()->route('posts.index')->with('error', '対象が見つかりませんでした');
}

サンプル:安全な取得(テナント + SoftDeletes + 404)

管理画面では削除済みも対象、一般画面では削除済み除外、など現実的な構成。

// 例: 管理者は削除済みも含めて取得、一般は除外
$query = Post::query()
  ->where('company_id', auth()->user()->company_id);

if (auth()->user()->is_admin) {
  $query->withTrashed();
}

$post = $query->findOrFail($id);

チェックリスト(上から順に確認する)

1) 例外メッセージの model名 と ID(No query results for model …)が想定どおりか
2) findOrFail/firstOrFail/Route Model Binding のどれで落ちているか
3) 受け取っているID(ルート/フォーム)が本当にそのモデルの主キー(またはslug等)か
4) SoftDeletesで deleted_at が入っていないか(管理画面だけ withTrashed が必要ではないか)
5) グローバルスコープや company_id 等の条件で除外されていないか(withoutGlobalScopeが必要か)
6) 接続先DBが正しいか、環境差でデータ不足になっていないか(.env/設定キャッシュ)
7) 仕様として404でよいか、UI/APIとして別の返し方が必要か(HandlerでJSON化など)