In a Laravel 13 API, a missing booking returns a 404 naming App\Models\Booking, and a render callback typed ModelNotFoundException never runs; why, and what is the fix?
answer
- conversion happens before render callbacks
- ModelNotFoundException becomes NotFoundHttpException
- AuthorizationException becomes a 403
- HTTP messages are shown in production JSON
- check $e->getPrevious() in the callback
basics
~20 sBefore render callbacks run, Laravel's handler converts ModelNotFoundException into a NotFoundHttpException that keeps its 'No query results for model [...]' message, so the callback never matches. Type-hint NotFoundHttpException, check getPrevious(), and return your own body.
solid answer
~40 sThe handler's render path calls `prepareException()` before trying render callbacks. That step converts `ModelNotFoundException` (and `RecordNotFoundException`, `RecordsNotFoundException`, `BackedEnumCaseNotFoundException`) into `NotFoundHttpException`, and an `AuthorizationException` into a 403 `AccessDeniedHttpException`, or an `HttpException` with its own status when the denial carries one. A callback typed `ModelNotFoundException` therefore never matches. Worse, the new exception copies the original message, `No query results for model [App\Models\Booking] 42`, and production JSON prints HTTP exception messages, so the API leaks a class name and id. The fix is a callback typed `NotFoundHttpException` that checks `$request->is('api/*')` and `$e->getPrevious() instanceof ModelNotFoundException`, and returns your own message. Returning `null` for web requests keeps the branded `errors/404.blade.php`.
code
php · 24 lines<?php
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(web: __DIR__.'/../routes/web.php', api: __DIR__.'/../routes/api.php', health: '/up')
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->render(function (NotFoundHttpException $e, Request $request) {
if (! $request->is('api/*')) {
return null; // web keeps resources/views/errors/404.blade.php
}
$previous = $e->getPrevious();
$resource = $previous instanceof ModelNotFoundException
? class_basename($previous->getModel())
: 'Resource';
return response()->json(['message' => "{$resource} not found."], 404);
});
})->create();go deeper
Recall that a missing route-bound model gives a 404 and a denied authorization check gives a 403.
Explain that the handler converts framework exceptions into HTTP exceptions before render callbacks, and list the main conversions.
Diagnose callbacks that never fire, spot model names and ids leaking into API bodies, and fix it with getPrevious().
Treat error bodies as part of the public API contract and review them for information leakage like any other response.
## The symptom An airline's API exposes `GET /api/bookings/{booking}` with route model binding. For a booking that does not exist, the response is a 404, as expected, but the body reads: ```json {"message": "No query results for model [App\\Models\\Booking] 42"} ``` The team added `$exceptions->render(function (ModelNotFoundException $e) { ... })` to fix the wording, and it never fires. Both effects have one cause. ## Why the callback never matches Laravel's handler renders in a fixed order. After giving the exception's own `render()` method and `Responsable` exceptions a chance, it runs a **preparation step** that converts several framework exceptions into HTTP exceptions, and only then tries the render callbacks. The callbacks see the converted exception: | Thrown | Rendered as | Status | |---|---|---| | `ModelNotFoundException`, `RecordNotFoundException`, `RecordsNotFoundException` | `NotFoundHttpException` | 404 | | `BackedEnumCaseNotFoundException` (bad enum route value) | `NotFoundHttpException` | 404 | | `AuthorizationException` without a status | `AccessDeniedHttpException` | 403 | | `AuthorizationException` with a status, e.g. from a "deny as not found" response | `HttpException` | that status | | `OriginMismatchException` | `HttpException` | 403 | | `TokenMismatchException` | `HttpException` | 419 | | Symfony `RequestExceptionInterface` | `BadRequestHttpException` | 400 | A callback's type-hint is matched against the converted object, so `ModelNotFoundException` is never seen by a callback. The original survives as the new exception's **previous** exception. ## Why the message leaks For a missing model, the conversion passes the original message along, and `ModelNotFoundException` builds that message from the model class and the ids. When rendering JSON with `APP_DEBUG=false`, the handler prints an HTTP exception's message as-is, because HTTP exception messages are meant for clients. The result is an internal class name and the probed id in a public API. HTML users are less exposed: the branded `404.blade.php` shows the message only if it prints `$exception->getMessage()`. ## Why the conversion exists The conversion is deliberate. By turning model misses, authorization denials and malformed requests into HTTP exceptions early, the handler lets one set of defaults serve them all: the status code decides the error view, the JSON path reads the status and message from the same object, and `errors/404.blade.php` covers an unknown URL and a missing booking alike. It also means a developer who writes `Booking::findOrFail($id)` or relies on route model binding gets a correct 404 without writing any handler code. The cost is the one seen here: callbacks must be written against the converted types, and messages written for developers travel with the conversion. The original exception is never lost, because each converted exception is created with the original as its **previous** exception, and `ModelNotFoundException` still offers `getModel()` and `getIds()` there. ## The fix Type-hint the exception that actually reaches the callbacks, and use the previous exception to recover what was missing: 1. Register `$exceptions->render(function (NotFoundHttpException $e, Request $request) { ... })`. 2. Return `null` unless `$request->is('api/*')`, so web users keep the branded error page. 3. If `$e->getPrevious()` is a `ModelNotFoundException`, use `getModel()` to name the resource in your own words, such as `Booking not found.`, without the namespace or id. 4. Return `response()->json([...], 404)`. The same approach works for 403s: type-hint `AccessDeniedHttpException`, or `HttpException` and check the status, and write your own message. ## Alternatives and their trade-offs - **An exception's own `render()` method** runs before the conversion, so a domain exception such as `BookingNotFoundException` thrown from your code, not from route binding, can render itself. - **`$exceptions->map()`** converts one exception into another before both reporting and rendering. Mapping a domain exception straight to an HTTP exception is concise, but HTTP exceptions are on the handler's do-not-report list, so the mapped failure also disappears from the logs. - **`respond()`** can rewrite the final 404 body for every path, but it sees only the response, so recovering which model was missing is awkward. ## Checking the fix - Request a missing booking with and without `Accept: application/json` and confirm the JSON body and the HTML page. - Assert in a feature test that the JSON body does not contain the model's namespace or the requested id. - Check that a genuine unknown URL under `api/` still gets a sensible 404 body; its previous exception is not a model exception.
- Which status does a policy denial get, and how can it become a 404?An `AuthorizationException` without a status becomes a 403 `AccessDeniedHttpException`. If the denial carries a status, as a policy's deny-as-not-found response does, the handler builds an `HttpException` with that status, so it renders as a 404.
- Why can mapping a domain exception to NotFoundHttpException with map() hide failures from the logs?`map()` runs before both reporting and rendering, and `HttpException` with its subclasses is on the handler's internal do-not-report list. The mapped exception is never logged, which is fine for expected misses but wrong for real faults.
saying these in an interview costs you the question
- Render callbacks see ModelNotFoundException exactly as it was thrown
- Production JSON hides every exception message behind 'Server Error'
- A denied policy always renders as 401 Unauthorized
- Fixing the 404 wording requires setting APP_DEBUG to false
- The original ModelNotFoundException is lost after conversion